A robust agent should not decide that a page “looks like” an AWS WAF challenge. It should receive a typed signal from deterministic code, run only tools approved for that target and purpose, and continue only after the original operation has been verified.
This tutorial builds that boundary around LangChain or LangGraph. The examples are for systems you own or are explicitly authorized to automate.
What AWS WAF changes in an agent workflow
AWS documents two response patterns that matter here:
- Challenge: HTTP
202withx-amzn-waf-action: challenge - CAPTCHA: HTTP
405withx-amzn-waf-action: captcha
With a valid token, the request continues to the next rule. Without one, a browser may receive a JavaScript interstitial. A successful interaction updates client token state and resubmits the request.
An agent-facing HTTP tool can misread these responses as success, an empty page, or a transient server error. The fix is not more prompting. The host application should classify the response and route it through a bounded recovery path.
Use five separate components
Keep these responsibilities independent:
- Agent: selects the next business action from a restricted tool set.
- HTTP/browser client: owns cookies, headers, and page state.
- Policy gate: checks target, purpose, redirects, and retry budget.
- Challenge adapter: exposes only documented capabilities needed by the recovery node.
- Verifier: repeats the intended action and checks an application-owned success condition.
CapSolver’s agent tools guide describes capsolver-agent as an adapter over capsolver-core. Its LangChain path supplies framework-compatible tools through get_langchain_tools(). That lets application code decide when a tool can run while the model sees only a narrow contract.
Prepare an authorized test environment
Define the boundary before installing packages:
- allowlisted hostnames you own or have explicit permission to test;
- approved purposes such as QA validation;
- a secret store for model and CapSolver credentials;
- one session owner for detection, recovery, retry, and verification;
- a small retry and total-time budget;
- a specific success assertion;
- a review route for unknown signals or state-changing actions;
- redaction rules for tokens, cookies, credentials, and page data.
Set up an isolated environment:
python -m venv .venv
source .venv/bin/activate
pip install git+https://github.com/capsolver-ai/capsolver-core.git
pip install "capsolver-agent[langchain] @ git+https://github.com/capsolver-ai/capsolver-agent.git"
pip install langchain-openai langgraph playwright
playwright install chromium
Load credentials from the environment for local development:
export CAPSOLVER_API_KEY="set-this-in-your-secret-manager"
export OPENAI_API_KEY="set-this-in-your-secret-manager"
Do not put real values in prompts, traces, notebooks, issues, or graph checkpoints. Use a managed secret store in deployed environments.
Classify the WAF response outside the model
Require the documented header and status together:
from dataclasses import dataclass
from typing import Mapping, Literal
WafAction = Literal["challenge", "captcha", "none", "unknown"]
@dataclass(frozen=True)
class WafSignal:
action: WafAction
status_code: int
needs_review: bool = False
def classify_aws_waf_response(
status_code: int,
headers: Mapping[str, str],
) -> WafSignal:
normalized = {key.lower(): value.lower() for key, value in headers.items()}
action = normalized.get("x-amzn-waf-action", "")
if action == "challenge" and status_code == 202:
return WafSignal(action="challenge", status_code=status_code)
if action == "captcha" and status_code == 405:
return WafSignal(action="captcha", status_code=status_code)
if action in {"challenge", "captcha"}:
return WafSignal(
action="unknown",
status_code=status_code,
needs_review=True,
)
return WafSignal(action="none", status_code=status_code)
A 202 by itself may be a valid asynchronous response. A 405 by itself may simply reject the method. Mismatched signals should lead to review.
AWS notes that cross-origin browser JavaScript cannot read x-amzn-waf-action through CORS. In that case, classify the network response in the automation layer or use an owned same-origin integration; do not infer the result from page text.
Add a deterministic authorization gate
The model should never expand the list of allowed targets:
from dataclasses import dataclass
from urllib.parse import urlparse
@dataclass(frozen=True)
class PolicyDecision:
allowed: bool
reason: str
ALLOWED_HOSTS = {"staging.example.com", "research.example.org"}
ALLOWED_PURPOSES = {"qa-validation", "authorized-research"}
def authorize_recovery(
url: str,
purpose: str,
attempts: int,
) -> PolicyDecision:
host = (urlparse(url).hostname or "").lower()
if host not in ALLOWED_HOSTS:
return PolicyDecision(False, "host-not-allowed")
if purpose not in ALLOWED_PURPOSES:
return PolicyDecision(False, "purpose-not-allowed")
if attempts >= 2:
return PolicyDecision(False, "retry-budget-exhausted")
return PolicyDecision(True, "authorized")
In production, read this configuration from a reviewed, versioned source. Validate redirects too, and record only non-sensitive policy metadata.
Register the smallest CapSolver tool set
The documented adapter can create LangChain-compatible tools:
import os
from capsolver_agent.langchain import get_langchain_tools
capsolver_tools = get_langchain_tools(
api_key=os.environ["CAPSOLVER_API_KEY"],
)
LangChain and LangGraph construction APIs evolve, so isolate this wiring and pin versions that pass your integration tests. Do not give every agent every tool. Make challenge handling available only from the recovery subgraph after authorization succeeds.
CapSolver documents mappings for solve_captcha, detect_captchas, and solve_on_page, backed by corresponding core capabilities. Choose the smallest appropriate capability. A browser-oriented flow can preserve page context without exposing raw solution data to the model.
Keep the browser out of graph state
AWS WAF token documentation explains that Challenge and CAPTCHA actions use client tokens to track completed interactions. Starting a new browser between detection and retry can discard that state.
Store browser objects in an application-owned registry:
class BrowserRegistry:
def __init__(self) -> None:
self._pages: dict[str, object] = {}
def register(self, page_id: str, page: object) -> None:
self._pages[page_id] = page
def get(self, page_id: str) -> object:
if page_id not in self._pages:
raise KeyError("browser page is not registered")
return self._pages[page_id]
async def close(self, page_id: str) -> None:
page = self._pages.pop(page_id, None)
if page is not None:
await page.close()
Graph state needs only the opaque page_id, current URL, purpose, attempt count, and typed status. Exclude cookies, storage, tokens, API keys, raw HTML, and browser objects.
Route on typed results
Use a compact state contract:
from typing import Literal, TypedDict
RecoveryStatus = Literal[
"not_needed",
"authorized",
"resolved",
"retry",
"review",
"denied",
]
class RecoveryState(TypedDict, total=False):
request_id: str
purpose: str
current_url: str
page_id: str
attempts: int
waf_action: str
recovery_status: RecoveryStatus
error_code: str | None
final_assertion_passed: bool
def route_after_detection(state: RecoveryState) -> str:
if state.get("waf_action") not in {"challenge", "captcha"}:
return "continue"
if state.get("recovery_status") == "authorized":
return "recover"
if state.get("recovery_status") in {"denied", "review"}:
return "human_review"
return "authorize"
def route_after_recovery(state: RecoveryState) -> str:
status = state.get("recovery_status")
if status == "resolved":
return "verify"
if status == "retry":
return "authorize"
return "human_review"
The recovery node may call the approved tool, but it should return only a typed status and stable error code—not the raw tool response.
Verify the original operation
Challenge completion is not proof that the business action succeeded. Repeat the intended operation in the same session and assert something your application controls:
async def verify_expected_page(page, expected_url_prefix: str) -> bool:
await page.wait_for_load_state("domcontentloaded")
if not page.url.startswith(expected_url_prefix):
return False
marker = page.get_by_test_id("authorized-content")
try:
await marker.wait_for(state="visible", timeout=15_000)
return True
except Exception:
return False
Prefer a stable test ID, expected API response, or known state transition. Avoid broad text checks because error pages often contain plausible-looking words.
If verification fails, reclassify the current response and inspect session state. Do not immediately invoke another recovery attempt.
Bound retries, logs, and monitoring
Route known, authorized signals to recovery. Route mismatches, unexpected redirects, missing sessions, and exhausted budgets to review or denial. A transient timeout may receive one retry if the total budget allows it; there should be no unbounded loop.
Log events such as waf_signal_detected, policy_allowed, recovery_started, recovery_finished, and page_verified. Include an internal request ID, hostname, duration, attempt number, and stable error code. Exclude credentials, cookies, tokens, raw challenge data, and sensitive page content.
AWS exposes CloudWatch metrics for Challenge and CAPTCHA activity in its WAF metrics reference. Correlate agent decisions and infrastructure metrics using an internal request ID—not a credential or token.
Test the failure paths
Before production, test:
- normal response without a challenge;
- documented Challenge signal;
- documented CAPTCHA signal;
- header/status mismatch;
- unapproved hostname;
- redirect outside the allowlist;
- missing browser session;
- tool timeout;
- failed final assertion;
- retry-budget exhaustion.
Unit-test the classifier, policy gate, and verifier. Use credentialed end-to-end tests only on an approved environment. Add explicit assertions that logs contain no credentials, cookies, or token values.
The architecture in one line
The dependable sequence is:
detect → authorize → recover → retry original action → verify → continue/review
For authorized automation, CapSolver supplies documented agent and core layers for LangChain integrations. Your application should still own policy, secrets, session state, retries, and the final assertion.
Use the current CapSolver documentation when implementing the integration, and do not guess API fields from outdated snippets.

Top comments (0)