跪拜 Guibai
← Back to the summary

How AI Agents Actually Drive a Browser: Accessibility Trees, Not Pixels

How AI Operates Browsers — The Implementation Principles of Browser Use

Author: Tan Sir Tags: Frontend, JavaScript, AI Programming

AI Agents operate browsers in two ways. The first is to send a screenshot to the LLM, and the LLM returns an (x, y) coordinate, allowing the browser automation tool to simulate a click at that coordinate. The second is to convert the web page into a numbered text tree; the LLM selects a number from the text tree, and the browser automation tool locates the real DOM element by that number and then performs the operation.

Currently, mainstream AI Agent CLIs (Gemini CLI, Claude Code, Codex, etc.) default to the second method: text tree + numbering, using the first method only when visual recognition is required. This article will explain the principles of both the text tree and visual implementation methods using the source code of x-code-cli.

Why Browser Use Is Needed

Many AI Agents come with a webFetch tool to grab web page content. However, webFetch is essentially a stateless HTTP request — it has no login state, does not execute JavaScript, and does not support multi-step interactions. For example, it cannot handle the following scenarios:

These are precisely the scenarios where browser use comes in: launching a real browser via automation tools like Playwright to perform login, rendering, clicking, form-filling, and other operations, just like a human operating the page.

Conversely, if you are just reading a public static article, a single fetch with webFetch is sufficient; if you are searching for information, you can use the webSearch tool. Simply put: for reading static public page content, webFetch is enough; for scenarios requiring login, JS rendering, or multi-step interaction, browser use is necessary.

LLM Automatic Judgment

Users do not need to judge for themselves when to use webFetch and when to use browser use. The LLM will decide on its own based on the tool description whether the current task should use webFetch or browser use. The tool description for the sub-agent named browser in x-code-cli is written as follows:

Drive a real, interactive web browser to do what webFetch and webSearch cannot: navigate, click, fill and submit forms, log in, and work through multi-step flows. Do NOT use it just to read, summarize, or extract text from a static URL; webFetch and webSearch are faster and cheaper for that.

After reading this description, the LLM will decide on its own whether to delegate to this sub-agent based on the user's needs.

Of course, this mainly depends on the LLM's judgment capability and the quality of the tool description. If the description is clear enough and the LLM is a strong model, the delegation will mostly hit the mark; but if the page "looks static" or the LLM is weak, it might use webFetch and fail to grab the content.

Live Demonstration

Let's look at the actual effect with a real example below (Note: browser use is not enabled by default; you need to manually enable it using the /browser on command or configure browser.enabled: true).

After starting x-code-cli in the terminal, enter the following command:

Help me check flights from Beijing to Shanghai on Qunar.com, find the 3 cheapest flights, and list their times and prices.

This task cannot be completed by the webFetch tool. Qunar.com is a pure SPA site; flight data is dynamically loaded via JS, and it requires inputting the departure city, destination city, departure date, and finally clicking search — these four interactive steps are indispensable. webFetch cannot even get past the first step, let alone the subsequent ones.

In the command entered above, neither "browser" nor "browser use" was mentioned; it was entirely up to the LLM to decide which tool to use.

Insert image description here

The LLM made a judgment based on the browser sub-agent's tool description: SPA, requires multi-step interaction, should delegate to the browser sub-agent. The browser sub-agent silently starts a new Chrome process without disturbing the user's ongoing activities.

Insert image description here

The Browser sub-agent opens the Qunar homepage, fills in the form, clicks search, and after the search results load, obtains a text tree snapshot — flight numbers, times, and prices are all in the snapshot, and the LLM extracts them directly.

Insert image description here

The Page the LLM Sees: From HTML to Semantic Tree

Continuing with the Qunar flight search example. After opening the homepage in the browser, what exactly does the LLM "see"?

The page contains a lot of styles, scripts, and DOM nodes, but this raw content is not what is sent to the LLM. After conversion by the Playwright MCP server, the LLM receives a piece of YAML text like this:

- tab "Domestic Flights" [ref=e2] [selected] [cursor=pointer]
- tab "International/HK/Macau/Taiwan Flights" [ref=e3] [cursor=pointer]
- radio "One Way" [ref=e5] [checked] [cursor=pointer]
- radio "Round Trip" [ref=e6] [cursor=pointer]
- textbox "Departure City" [ref=e8]: Beijing(BJS)
- button "Swap" [ref=e10] [cursor=pointer]
- textbox "Arrival City" [ref=e12]: Shanghai(SHA)
- textbox "Departure Date" [ref=e15]: 2026-07-12
- button "Search" [ref=e20] [cursor=pointer]

The LLM does not process pixels, nor does it parse HTML; it receives this semantic tree. Each interactive element in the tree is tagged with a [ref=eNN] number: e8 is the departure city input box, e12 is the arrival city input box, e15 is the departure date, and e20 is the search button. The LLM operates the page through tool calls — for example, calling browser_fill_form to set e8 to "Beijing" and e12 to "Shanghai", then calling browser_click to click e20, triggering the search. After the search is complete, the page navigates to the results page, and the MCP server returns a new snapshot containing the complete flight list — Xiamen Airlines MF8561 07:50-09:45 ¥400, China Eastern MU9192 20:45-23:10 ¥420, China United Airlines KN5977 20:50-22:55 ¥420...

The LLM directly extracts the 3 cheapest from this and outputs them to the terminal. Most of the logic of browser use revolves around this tree. The following sections will break down how it is generated.

Two Element Locating Methods

The difference between these two methods lies in how the LLM obtains page information and how it locates the elements to operate on.

Method A: Screenshot + Coordinates (Visual / Computer Use)

Send a page screenshot to the LLM, the LLM returns an (x, y) coordinate, and the browser automation tool simulates a click at that coordinate. This method requires a multimodal model (supports visual recognition, more expensive), coordinates are unstable (fail if the page layout changes or resolution differs), and inference speed is slower. However, content rendered on canvas, games, videos, remote desktops, etc., lack DOM semantics, leaving the text tree empty, so only the visual method can be used. Anthropic's Computer Use and OpenAI's Operator adopt this approach.

Method B: Text Tree + Numbering (Accessibility Tree + ref)

Convert the page into a numbered text tree. The LLM initiates tool calls based on the ref numbers in the snapshot (e.g., browser_click clicks the e20 search button), and the browser automation tool finds the corresponding real DOM element by number and executes the operation. This approach does not require visual capability; any LLM can do it, including pure text models like DeepSeek.

These two methods are not mutually exclusive; they are generally used in combination: the text tree handles routine operations, and screenshots supplement scenarios the text tree cannot cover.

From Web Page to Numbered Text Tree

Origin: The Browser Has Always Maintained This Tree

Every mainstream browser kernel maintains an accessibility tree — a semantic structure generated based on the DOM tree, computed CSS styles, and ARIA attributes. It was originally designed for assistive technologies like screen readers and voice control, existing for over a decade, long before AI agents. Browser use directly reuses this technology: the accessibility tree is inherently designed for programmatic parsing of page content, making it perfectly suitable for LLMs.

Take <button>Submit</button> as an example; it will be parsed in the accessibility tree into the following content:

The role and name are inferred by the browser, mainly based on two things:

  1. Native HTML Semantics. <button> is recognized as a button, <a> as a link, <input> as an input box. Even without any ARIA attributes, the browser can still identify the role and name.
  2. ARIA Attribute Supplementation. Developers use attributes like role="dialog", aria-label="Search" to tell the browser "this <div> is a dialog". ARIA is supplementary, not mandatory.

Reading This Tree from the Browser

Reading the accessibility tree requires a browser automation tool. Playwright is used here — Microsoft's open-source browser automation framework, supporting Chrome, Firefox, WebKit, allowing code to control the browser for clicking, filling, taking screenshots, reading page content, etc. Frontend developers often use it for E2E testing; in the AI Agent scenario, it is used as the underlying engine for controlling the browser.

Playwright itself is a Node.js library; LLMs cannot call it directly. The @playwright/mcp npm package wraps Playwright's capabilities into MCP tools — essentially providing an interface layer for Playwright that LLMs can understand. This is what converts HTML into the semantic tree in the YAML snapshot example earlier.

Let's look at the entire reading process:

LLM calls the `browser_snapshot` tool
    ↓
The @playwright/mcp MCP server process receives the request and calls Playwright's page.ariaSnapshot({ mode: "ai" })
    ↓
Playwright sends a request via CDP (Chrome DevTools Protocol):
    Accessibility.getFullAXTree
    ↓
The browser kernel returns the complete accessibility tree (a huge JSON)
    ↓
Playwright does three things:
    1. Pruning: Removes invisible nodes, purely decorative nodes
    2. Numbering: Assigns ref numbers (e1, e2, e3...) to each interactive element
    3. Serialization: Renders into YAML format text
    ↓
The YAML snapshot is returned to the LLM as the tool return value

CDP is the debugging protocol Chrome exposes to external tools — the Inspect Element and Network panel in Chrome DevTools use CDP under the hood. Accessibility.getFullAXTree is an API method provided by CDP specifically for reading the page's complete accessibility tree; any tool that can connect to CDP can call it.

Why not use the DOM directly? Because the DOM's content is too vast and complex. An ordinary page might have thousands of nodes, many of which are style containers (<div class="flex gap-2 px-4">), SVG icons, hidden elements — this content is invalid information for an LLM. The accessibility tree filters this content: it only retains semantically meaningful content and unifies expression through roles — no matter how the HTML is written, a button is button, a link is link.

Serialization: From Accessibility Tree to YAML

Let's look at the serialization logic of ariaSnapshot({ mode: "ai" }):

After processing, hundreds of KB of HTML become a YAML with dozens of nodes. Information density is high, almost entirely valid information.

When the Accessibility Tree Fails

As long as the HTML is written to standards — using semantic tags like <button>, <a>, <input> — the browser can recognize the correct role and name. Therefore, pages should follow HTML5 semantics as much as possible, and most modern pages work fine.

Problems generally arise in these two situations:

In these two cases, the only option is to switch to the visual method (see the visual processing chapter later for details).

Complete Loop: Get Snapshot -> Select Number -> Operate -> Get New Snapshot

Let's return to the Qunar flight search example and break down each step from the LLM's perspective:

  1. The LLM calls the browser_snapshot tool to get the YAML snapshot of the homepage. The snapshot contains interactive elements like the departure city, arrival city input boxes, and the search button, along with their ref numbers.
  2. After analyzing the snapshot, the LLM initiates tool calls: browser_fill_form fills "Beijing" in e8 (departure city) and "Shanghai" in e12 (arrival city), browser_click clicks e20 (search button).
  3. Playwright uses the ref->DOM mapping table established during snapshot generation to find the real DOM elements corresponding to e8, e12, e20, and executes the fill and click operations in the browser.
  4. After the browser executes the operations, the page navigates to the search results page, and the MCP server automatically generates a new snapshot, which is sent back to the LLM as the return result of the tool call.
  5. The LLM directly extracts flight information from the new snapshot — Xiamen Airlines MF8561 ¥400, China Eastern MU9192 ¥420, China United Airlines KN5977 ¥420 — and finds the 3 cheapest.

Note that the automatic return of a new snapshot in step 4 is the core of this mechanism — for tools like browser_click that change page content, the operation execution and new snapshot acquisition are completed in the same round trip; the LLM does not need to manually call browser_snapshot again after each operation.

How ref Numbers Work

Numbers like [ref=e20] are not made up by the LLM; they are assigned by Playwright's internal computeAriaRef function during snapshot generation. This function maintains an incrementing counter, incrementing by 1 for each interactive element encountered, then concatenating ref strings like e1, e2, e3, only assigning them to visible and interactive elements.

The assigned number is cached as an internal property _ariaRef on the corresponding DOM element. As long as the element's role and name haven't changed, the snapshot will reuse the same number — numbers are relatively stable across multiple snapshots.

While assigning numbers, computeAriaRef also builds a bidirectional mapping table (stored inside the snapshot object): elements is ref->DOM, refs is DOM->ref.

After the LLM calls browser_click to click e20, Playwright registers a custom selector engine named aria-ref for this scenario. When page.locator("aria-ref=e20") is called, it does not use a CSS selector but directly retrieves the DOM element corresponding to e20 from the snapshot's internal bidirectional mapping table and checks if the element is still on the page (isConnected). If the element is no longer on the page, it throws an error: Ref e20 not found in the current page snapshot. Try capturing new snapshot. — this is the reason why "the number has expired and a new snapshot needs to be captured".

How Tools Are Exposed to the LLM

All operations involved in browser use (clicking, filling, taking screenshots, getting snapshots, etc.) are exposed to the LLM via MCP (Model Context Protocol). MCP is a unified external tool invocation protocol — @playwright/mcp is an MCP server that wraps Playwright's browser automation capabilities into a set of MCP tools.

These tools are defined in groups by capability. Among them, tools related to page interaction (click, fill_form, type, etc.) share the same set of parameters: an element (a human-readable description, which is displayed in the confirmation dialog when the permission mode is "ask every time") plus a target (the ref number from the snapshot).

When @playwright/mcp starts, it only activates the core capability group by default (using glob matching core*): snapshot, click, type, fill_form, navigate, tabs, screenshot, wait_for, etc. — note that screenshot is also in the default group, no need to enable vision mode. Only when --caps vision is added will an additional set of coordinate-based tools (mouse_click_xy, mouse_drag_xy, mouse_move_xy, mouse_down, mouse_up, mouse_wheel) be exposed. So the default is the text tree approach; coordinate operations need to be actively enabled.

Besides the tools themselves, the sub-agent's system prompt explicitly restricts the use of screenshots. The BROWSER_VISION_CAPTION_ADDENDUM constant in x-code-cli contains this rule:

KEEP the accessibility snapshot as your default — it's exact, cheaper, and gives you stable refs. Do NOT screenshot a page you can already read and act on from the snapshot.

The gist is: always prefer the accessibility tree snapshot; it is precise, low-cost, and has stable ref numbers; do not screenshot anything you can already read from the snapshot. Screenshots are only for scenarios the snapshot cannot cover, such as canvas, charts, purely visual elements, etc.

@playwright/[email protected] is now just a lightweight forwarding wrapper — the core snapshot generation, element locating, and ref numbering logic have all been merged into the playwright-core package. So referencing @playwright/mcp essentially means using the browser automation capabilities officially provided by Playwright.

How to Integrate into an Agent

x-code-cli implements browser use as an independent sub-agent, not as a tool of the main agent.

Design Reasons for Sub-agent Isolation

There are over 20 tools related to browser use (e.g., snapshot, click, type, fill, navigate, tabs, screenshot, etc.). Putting them all into the main agent's tool list would cause the following problems:

x-code-cli's approach: inject these tools into the private context of the browser sub-agent, and the main agent delegates tasks via task(subagent_type: "browser", ...). After the sub-agent completes execution, only the final text result is returned to the main agent; intermediate snapshots, screenshots, and multi-step operations are invisible to the main agent. This sub-agent isolation architecture is similarly applied in products like Gemini CLI.

Lazy Startup and Session-Level Caching

The MCP server used by the browser sub-agent (underlying is @playwright/mcp) is started only on first use (npx -y @playwright/mcp@latest --browser chrome), not when the CLI starts. After successful startup, the MCP server connection is cached, and subsequent browser sub-agent calls within the same CLI session will reuse the same connection and browser instance, avoiding repeated startup overhead.

If the browser or MCP server connection is disconnected (user closes Chrome or process crashes), the cache is automatically cleared, and the next task will reconnect. Connection failures are not cached — the user can retry after installing Chrome without restarting the CLI.

let cached: BrowserMcp | null = null
let connecting: Promise<BrowserMcp> | null = null

export async function getBrowserMcp(vision = false): Promise<BrowserMcp> {
  if (cached) return cached
  if (connecting) return connecting
  connecting = connectBrowser(vision)
  try {
    return await connecting
  } finally {
    connecting = null
  }
}

Permission Pre-authorization

Enabling the browser agent (/browser on or configuring browser.enabled: true) is itself the user's authorization action. Therefore, except for sensitive tools, all browser tools receive session-level pre-approval upon enabling, without triggering a confirmation for every navigate/click/snapshot.

Specifically divided into three tiers:

Visual Processing: When the Text Tree Cannot Cover

There are mainly two situations where the text tree cannot cover. One is content rendered on canvas — application interfaces, games, charts, which have no DOM structure; the accessibility tree cannot retrieve any semantics, and the snapshot is basically empty. The other is targets that depend on appearance to distinguish, such as when the user says "click that yellow button" or "look at the red error message"; the text tree has text and roles but no color or position information, making it impossible to locate. Both types of scenarios require supplementation via screenshots + coordinates.

Applicable Scenarios for Text Tree and Visual

In actual use, text tree tools and screenshot tools are simultaneously exposed to the LLM, and the rules in the system prompt guide the LLM to choose which tool to use based on page characteristics, rather than automatic switching by code.

x-code-cli's Visual Implementation

Visual capability is a model-level attribute: Claude, GPT-4o, Gemini, Qwen VL, Kimi support image input; DeepSeek is a pure text model and does not support it. Different models from the same provider may also differ (Qwen Max is a text model, Qwen VL supports images), so judgment can only be made based on the specific model ID.

There are two enabling conditions: first, the user has not explicitly disabled the visual feature via config.browser.vision = false; second, the current main model supports image input (determined by the modelSupportsVision function based on the model ID, e.g., returns true for GPT-4o, Claude, Gemini, false for DeepSeek). When both conditions are met, the MCP server starts with --caps vision, additionally exposing screenshot and coordinate click tools beyond the text tree tools.

The sub-agent's system prompt varies based on visual capability into three types:

How Screenshots Are Delivered to the LLM

There is an integration issue here: only Anthropic's API supports directly placing images in tool_result; OpenAI-compatible providers (DeepSeek, Kimi, Qwen, GLM, xAI) will JSON.stringify the tool_result content, turning the image into a long string of base64 text — the LLM cannot extract visual information from it and may directly exceed the context length limit.

So screenshot delivery takes two paths:

The vision model used for description is selected by cost priority: Gemini 2.5 Flash (large free quota) -> GLM-4V Flash (free) -> Qwen-VL Plus -> GPT-4o Mini -> others. A timeout mechanism is added; if description is too slow, it degrades to using only the text tree.

Automatic Normalization of Screenshot Parameters

x-code-cli hardcodes the processing of screenshot parameters:

Key Mechanisms for Saving Tokens

Only Keeping the Latest Snapshot

The browser sub-agent repeatedly calls snapshot and screenshot during multi-step tasks, and the return value of each call stays in the conversation history. These old snapshots and screenshots have no reference value for subsequent operations, but the LLM has to resend all of them in every request round. If a task calls snapshot 10 times, 10 snapshots accumulate in the history, each potentially containing hundreds of nodes — and these expired snapshots are worthless for subsequent operations, purely extra token overhead.

Before each request round is sent, x-code-cli replaces the tool return values of old snapshots and screenshots with a single placeholder line, keeping only the latest one:

const placeholder = `[Older ${suf} result dropped to save context — only the most recent is kept.]`

This idea references Gemini CLI's approach (Gemini CLI uses the onBeforeTurn hook to replace all non-latest snapshots with placeholders before each model call round). x-code-cli only enables this mechanism for the browser sub-agent (controlled via AgentOptions.collapseStaleToolResults).

Token Cost of Screenshots

The token cost of screenshots is calculated by resolution (the model divides the image into fixed-size patches and encodes them block by block), unrelated to file size. So compressing screenshots into JPEG only reduces transmission volume and time, not token consumption. Specific means of saving tokens have already been mentioned earlier and will not be repeated here.

When using the description path, the image does not enter the main agent's conversation history — it only appears in the one-time call of the borrowed vision model. When using the Anthropic inline path, the Anthropic server automatically resizes the image to about 1568px, costing roughly over a thousand tokens per image.

How to Write the System Prompt

Providing tools alone is insufficient — the LLM needs clear rules to constrain operational behavior. The system prompt of x-code-cli's browser sub-agent specifies the following rules:

These rules are similarly reflected in other AI Agent CLI products and represent common best practices for browser use scenarios.

Costs and Boundaries

Token Cost

For content-dense pages, a single snapshot may have hundreds of nodes, and a single request may consume thousands of tokens. Multi-step tasks repeatedly call snapshot; without processing, the token cost of these snapshots grows linearly with the number of steps. The previously mentioned practice of only keeping the latest snapshot is the most effective means of saving tokens.

Boundaries

Competitive Comparison

Product Browser Approach Visual Processing Characteristics
x-code-cli @playwright/mcp, tree-first (browser_snapshot + ref), visual auto-enabled based on model capability Non-Anthropic uses caption (borrows vision model to convert to text description) Cross-provider, usable even by pure text models
Gemini CLI Built-in browser_agent, underlying chrome-devtools-mcp + puppeteer-core, tree-first (take_snapshot + uid) analyze_screenshot separates recognition and execution; image only enters vision model's one-time call, not browser agent's conversation history Built-in sub-agent architecture, supports persistent / isolated / existing three session modes
Claude Code claude-in-chrome (Chrome extension communicates via Native Messaging) + desktop-level computer use (@ant/computer-use-mcp, macOS only) computer use is pure visual screenshot + coordinate approach Extension approach can reuse user's logged-in Chrome session (cookies, OAuth)
Codex CLI No built-in browser use; users can integrate via MCP server configuration Depends on configured MCP server Rust implementation, general MCP framework support

My Other AI Agent Articles

References