Mapping ReAct to CLI Browser Commands
Updated Jul 2026
Bridging Theory and Practice
The ReAct pattern is abstract: Observe, Think, Act, Evaluate. To make it concrete, we need to map each phase to actual commands that an AI agent can execute. The Playwright CLI (playwright-cli) — the 2026 default for agent-driven browser control — provides exactly these commands, making it a practical implementation of the ReAct loop for browser testing. (Vibium's vibe-check CLI, which earlier editions of this chapter used, implements the same mapping with a selector-based surface; the loop is identical.)
The Phase-to-Command Mapping
| ReAct Phase | Testing Activity | Playwright CLI Commands |
|---|---|---|
| Observe | Read the current page state | playwright-cli snapshot (YAML with element refs, saved to disk), playwright-cli screenshot, playwright-cli eval "<js>" |
| Think | Decide what to test based on observation | Agent reasons internally (no command — LLM inference) |
| Act | Execute the test action | playwright-cli fill <ref> "<text>", playwright-cli click <ref>, playwright-cli goto "<url>", playwright-cli press <key> |
| Evaluate | Check the result against expectations | Fresh snapshot + read it, eval for dynamic state |
Observation Commands in Detail
The observation phase is about gathering state. The CLI's key design choice: state lands on disk, and the agent reads only what it needs:
# Save a structured page snapshot (YAML accessibility summary with refs)
playwright-cli snapshot
# → .playwright-cli/snapshot-....yml — the agent Reads this file on demand
# Capture visual state
playwright-cli screenshot
# Query dynamic state directly
playwright-cli eval "document.querySelectorAll('.error').length"
playwright-cli eval "location.href"
A snapshot looks like:
- textbox "Email" [ref=e8]
- textbox "Password" [ref=e9]
- button "Sign In" [ref=e11]
- link "Forgot Password?" [ref=e12]
Element refs (e8, e11) are how the agent addresses elements in the Act phase — no CSS selector authoring.
Action Commands in Detail
# Navigation
playwright-cli open "https://app.example.com/login"
playwright-cli goto "https://app.example.com/settings"
# Form interaction (refs come from the latest snapshot)
playwright-cli fill e8 "user@test.com"
playwright-cli fill e9 "secret123"
playwright-cli select e14 "United States"
# Clicking
playwright-cli click e11
playwright-cli dblclick e20
# Keyboard
playwright-cli press Enter
playwright-cli press Control+a
# Other input
playwright-cli hover e17
playwright-cli check e21
playwright-cli upload e23 ./fixture.pdf
Auto-waiting is built into the engine — every action waits for the element to be actionable, so the agent doesn't hand-roll waits.
A Complete ReAct Test Using the CLI
Here is how an agent thinks through a login test using the ReAct loop:
TEST OBJECTIVE: Verify that submitting the login form with empty fields
shows appropriate validation errors.
STEP 1 — OBSERVE:
$ playwright-cli open "https://app.example.com/login"
$ playwright-cli snapshot
→ reads snapshot: textbox "Email" [ref=e8], textbox "Password" [ref=e9],
button "Sign In" [ref=e11]
STEP 2 — THINK:
"I see a login form with email and password fields and a Sign In button.
My objective is to test empty field submission. I should click Sign In
without filling in any fields."
STEP 3 — ACT:
$ playwright-cli click e11
STEP 4 — OBSERVE:
$ playwright-cli snapshot
→ reads snapshot: alert "Email is required" [ref=e15],
alert "Password is required" [ref=e16], textbox "Email" [ref=e8] ...
STEP 5 — THINK:
"Two validation errors appeared: one for email and one for password.
Both say the field is required. This matches expected behavior.
Let me also verify we stayed on the login page (no navigation)."
STEP 6 — OBSERVE:
$ playwright-cli eval "location.href"
→ "https://app.example.com/login"
STEP 7 — THINK:
"URL has not changed. We are still on the login page. Both required
field errors are shown. Test objective is met."
STEP 8 — EVALUATE:
DONE pass "Empty form submission correctly shows validation errors
for both email ('Email is required') and password ('Password is required').
Page did not navigate away from /login."
Implementing the Agent in Python
import subprocess
import json
from pathlib import Path
class CliReActAgent:
"""A ReAct test agent driving the browser via playwright-cli."""
def __init__(self, llm, max_steps=15):
self.llm = llm
self.max_steps = max_steps
self.history = []
def cli(self, command: str) -> str:
"""Execute a playwright-cli command and return its (minimal) output."""
result = subprocess.run(
f"playwright-cli {command}",
shell=True, capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
return f"ERROR: {result.stderr.strip()}"
return result.stdout.strip()
def observe(self) -> dict:
"""Gather current page state. Snapshot goes to disk; read it back."""
snapshot_path = self.cli("snapshot") # returns a file path
snapshot = ""
if snapshot_path and not snapshot_path.startswith("ERROR"):
snapshot = Path(snapshot_path.split()[-1]).read_text()[:2000]
return {
"url": self.cli('eval "location.href"'),
"snapshot": snapshot,
"errors": self.cli('eval "document.querySelectorAll(\'.error\').length"'),
}
def run(self, objective: str) -> dict:
"""Execute a test using the ReAct loop."""
for step in range(self.max_steps):
# OBSERVE
state = self.observe()
# THINK + decide
prompt = f"""
You are a QA test agent. Your objective: {objective}
Current state:
- URL: {state['url']}
- Page snapshot (YAML, first 2000 chars): {state['snapshot']}
- Error count: {state['errors']}
History (last 5 actions):
{json.dumps(self.history[-5:], indent=2)}
Respond with exactly ONE action:
CLI <command> — execute a playwright-cli command (use refs from the snapshot)
PASS <reason> — test passed, explain why
FAIL <reason> — test failed, explain why
"""
response = self.llm.generate(prompt).strip()
self.history.append({"step": step, "state_url": state["url"], "action": response})
# ACT or EVALUATE
if response.startswith("CLI "):
command = response[4:]
output = self.cli(command)
self.history[-1]["output"] = output
elif response.startswith("PASS "):
return {"status": "pass", "reason": response[5:], "steps": step + 1}
elif response.startswith("FAIL "):
return {"status": "fail", "reason": response[5:], "steps": step + 1}
return {"status": "timeout", "reason": f"Exceeded {self.max_steps} steps", "steps": self.max_steps}
Note what the disk-first design does to this loop: the full snapshot only enters the LLM prompt when observe() deliberately includes it. A production version would be smarter still — passing the snapshot path and letting the agent read it only when its reasoning requires.
Error Recovery Patterns
One of the most powerful aspects of mapping ReAct onto a CLI is the agent's ability to recover from errors:
Stale Ref Recovery
Agent tries: playwright-cli click e11
Output: ERROR: ref e11 not found (page changed since last snapshot)
Agent observes: playwright-cli snapshot
→ button "Submit" is now [ref=e31]
Agent reasons: "The page re-rendered and refs changed. The submit button
is now e31."
Agent acts: playwright-cli click e31
Output: success
Unexpected State Recovery
Agent tries: playwright-cli click e24 # checkout button
Output: success
Agent observes: playwright-cli snapshot
→ heading "Your session has expired. Please log in again."
Agent reasons: "Session expired during checkout. This is an environment
issue. I need to log in again before continuing the test."
Agent acts:
playwright-cli state-load auth-state # restore saved login state
playwright-cli goto "https://app.example.com/checkout"
// Then retries the checkout
Performance Considerations
| Metric | CLI ReAct Agent | Direct Playwright Script |
|---|---|---|
| Steps per test | 8-20 | Fixed (known at write time) |
| Time per step (LLM) | 200-500ms | 0ms (no reasoning) |
| Time per step (browser) | 100-300ms | 100-300ms |
| Total test time | 5-15 seconds | 1-3 seconds |
| Adaptability | High | None |
| Debugging difficulty | Medium (history + traces) | Low (linear script) |
The Same Mapping in Commercial SDKs
This phase-to-command mapping is not unique to the Playwright CLI — it is exactly what production agentic browser SDKs expose. Stagehand (Browserbase) collapses Think+Act into a single intent call: act("click the submit button") resolves natural language against the observed page, the same way our agent resolves "click Sign In" to a ref. browser-use does the same in Python at framework scale. Vibium's vibe-check does it with CSS selectors over WebDriver BiDi. Once you understand the mapping in this file, those SDKs are just packaged, hardened versions of the loop you can already build by hand.
Key Takeaway
The Playwright CLI provides a natural mapping from the abstract ReAct pattern to concrete browser automation commands: observation via disk-saved snapshots (read on demand — the token-economics win), action via ref-based click/fill/goto/press, and evaluation via fresh snapshots and eval checks. This mapping turns the theoretical ReAct loop into a practical, implementable test agent — and it is the same mapping that production SDKs like Stagehand and browser-use package behind their intent APIs.