Logical Architecture - swisherae/browser-use GitHub Wiki

Part 1: Architectural Style

Verdict: a hybrid — layered at the package level, event-driven inside the browser layer, and client-server at both outer edges.

browser-use has no controllers/, views/, routes/ or repositories/ anywhere under browser_use/, so it is neither MVC nor a repository-style layout. It is split by capability instead: agent/ decides what to do next, tools/ carries those decisions out, browser/ runs the browser, dom/ reads the page, and llm/ talks to AI providers. Counting every from browser_use.<package> import shows the dependencies running consistently downward through those packages, with no shipped code importing back up a layer. Inside browser/ the style changes: components talk through a bubus.EventBus rather than calling each other. At the edges the project is client-server twice over, once to a separate Chrome process and once to each AI provider's API.

Evidence

Claim Where I found it
Layered, top down agent imports llm 15×, browser 8×, dom 5×, tools 3×; tools imports browser 7× and dom 4×; browser imports dom 9×
Nothing imports back up dom/service.py:29 and actor/page.py import BrowserSession only inside if TYPE_CHECKING:; the only llm → agent imports are in browser_use/llm/tests/
Event-driven browser layer Tools dispatches ClickElementEvent without naming a handler (tools/service.py:731); 15 watchdogs in browser/watchdogs/ subscribe by method name, e.g. DefaultActionWatchdog.on_ClickElementEvent (default_action_watchdog.py:337)
Client-server edges cdp-use sends JSON-RPC over a websocket to Chrome; each package under llm/ calls a vendor HTTP API

Where it isn't textbook: the layering is a convention, not something enforced. There are no port or interface packages between layers, agent/ reaches past tools/ to import browser/ and dom/ directly, and one real cycle exists between BrowserSession and the watchdogs, which Part 4 documents.

Part 2: Logical Architecture Diagram

Every box is a real directory or module in the repository. Solid arrows point from the package that imports to the package it imports. Dashed arrows are either imports that skip a layer, or calls that leave the codebase.

flowchart TD
    subgraph entry["Entry points"]
        EX["examples/"]
        CLI["browser_use/cli.py<br/>browser_use/skill_cli/"]
        MCP["browser_use/mcp/<br/>server.py, client.py"]
    end

    subgraph decide["Decision layer"]
        AG["browser_use/agent/<br/>service.py (Agent), views.py"]
        MM["browser_use/agent/message_manager/<br/>service.py (MessageManager)"]
    end

    subgraph act["Action layer"]
        TO["browser_use/tools/<br/>service.py (Tools), registry/"]
    end

    subgraph browser["Browser layer"]
        BS["browser_use/browser/<br/>session.py (BrowserSession), events.py, profile.py"]
        WD["browser_use/browser/watchdogs/<br/>15 watchdog classes"]
        AC["browser_use/actor/<br/>page.py, mouse.py, element.py"]
    end

    subgraph page["Page layer"]
        DOM["browser_use/dom/<br/>service.py (DomService), views.py, serializer/"]
    end

    subgraph model["Model layer"]
        LLM["browser_use/llm/<br/>base.py (BaseChatModel)"]
        PROV["browser_use/llm/google, anthropic,<br/>openai, deepseek, ... (14 providers)"]
    end

    subgraph shared["Cross-cutting, used by every layer"]
        CFG["config.py, logging_config.py,<br/>observability.py, utils.py"]
        TOK["browser_use/tokens/"]
        FS["browser_use/filesystem/"]
        TEL["browser_use/telemetry/"]
    end

    subgraph outside["Outside the codebase"]
        BUS["bubus.EventBus"]
        CDP["cdp-use over a websocket<br/>to Chrome"]
        API["provider HTTP APIs"]
    end

    EX --> AG
    CLI --> AG
    MCP --> AG
    AG --> MM
    AG --> TO
    AG --> LLM
    TO --> BS
    TO --> DOM
    BS --> WD
    BS --> AC
    BS --> DOM
    WD --> DOM
    LLM --> PROV
    AG -. also imports directly .-> BS
    AG -. also imports directly .-> DOM
    AG -.-> shared
    BS -.-> shared

    WD -. publishes and handles events on .-> BUS
    BS -. drives .-> CDP
    PROV -. calls .-> API
Loading

How to read it

  • Every arrow points down a layer, except the two dashed ones labelled "also imports directly". Those mark where agent/ reaches past tools/ straight into browser/ and dom/.
  • The browser layer is where the style changes. BrowserSession and the watchdogs don't call each other directly; they pass events through bubus.EventBus.
  • Nothing in the model layer points back up. llm/ depends only on the cross-cutting modules and on the provider APIs, which is what makes swapping providers cheap.
  • Source: docs/architecture.md, rendered copy at docs/architecture.png.

Part 3: Interaction Diagrams

Both diagrams are object-level. Every participant is a real class, listed with the file and line where it's defined, so each one can be opened on request.

3.1 Inside runTask(stepLimit) — one agent step

This opens up the runTask(stepLimit) system event from SSD 2 on the SSDs and Operation Contracts page, where the whole system was a single box.

Participant Defined at
Agent browser_use/agent/service.py:2503
BrowserSession browser_use/browser/session.py:1595
DOMWatchdog browser_use/browser/watchdogs/dom_watchdog.py:244
DomService browser_use/dom/service.py:703
MessageManager browser_use/agent/message_manager/service.py:424
ChatGoogle browser_use/llm/google/chat.py
Tools browser_use/tools/service.py:2178
DefaultActionWatchdog browser_use/browser/watchdogs/default_action_watchdog.py:337
sequenceDiagram
    participant AG as :Agent
    participant BS as :BrowserSession
    participant DW as :DOMWatchdog
    participant DS as :DomService
    participant MM as :MessageManager
    participant LLM as :ChatGoogle
    participant TO as :Tools
    participant AW as :DefaultActionWatchdog

    loop until done, or stepLimit reached
        AG->>BS: get_browser_state_summary()
        BS->>DW: on_BrowserStateRequestEvent(event)
        DW->>DS: get_dom_tree()
        DS-->>DW: EnhancedDOMTreeNode tree
        DW-->>BS: BrowserStateSummary
        BS-->>AG: BrowserStateSummary
        AG->>MM: create_state_messages(state, history)
        MM-->>AG: list[BaseMessage]
        AG->>LLM: ainvoke(messages, AgentOutput)
        LLM-->>AG: AgentOutput(evaluation, next_goal, actions)
        AG->>TO: act(action, browser_session)
        TO->>AW: ClickElementEvent(node) via event bus
        AW-->>TO: click metadata
        TO-->>AG: ActionResult
    end
    AG-->>AG: AgentHistoryList.final_result()
Loading

The loop is the agent's step cycle: capture the page, ask the model, carry out the actions, repeat. Tools reaches DefaultActionWatchdog only through the event bus, which is the Indirection discussed in Part 5.

3.2 Inside the navigate(url) action — the security check

A different operation: opening a page, and what happens when the site isn't on the approved list.

Participant Defined at
Tools browser_use/tools/service.py:507
EventBus bubus package
BrowserSession browser_use/browser/session.py:905
SecurityWatchdog browser_use/browser/watchdogs/security_watchdog.py:35
sequenceDiagram
    participant TO as :Tools
    participant EB as :EventBus
    participant BS as :BrowserSession
    participant SW as :SecurityWatchdog

    TO->>EB: dispatch(NavigateToUrlEvent(url))
    EB->>BS: on_NavigateToUrlEvent(event)
    BS-->>EB: page load started
    EB->>SW: on_NavigateToUrlEvent(event)
    SW->>SW: _is_url_allowed(url)
    alt url matches a SiteRule
        SW-->>EB: no objection
        EB-->>TO: event_result() returns
        TO-->>TO: ActionResult(extracted_content="navigated")
    else url is not allowed
        SW->>EB: dispatch(BrowserErrorEvent(NavigationBlocked))
        SW->>BS: Page.navigate(about:blank)
        SW-->>EB: raise ValueError("blocked by security policy")
        EB-->>TO: event_result() re-raises
        TO-->>TO: ActionResult(error="Navigation failed: ... blocked by security policy")
    end
Loading

Finding: BrowserSession.on_NavigateToUrlEvent runs before SecurityWatchdog.on_NavigateToUrlEvent, so the page starts loading before the check runs. I confirmed this with allowed_domains=['example.com']: the tab was switched to about:blank after the fact, and the blocked site still received the request. The site stays blocked, but anything in the URL has already left the machine.

Source: docs/interaction-diagrams.md, with rendered copies at docs/interaction-1-runtask.png and docs/interaction-2-navigate.png.

Part 4: One Architectural Concern

A circular dependency between BrowserSession and the watchdogs, hidden by deferred imports.

Every watchdog depends on the session, declared at module level:

# browser_use/browser/watchdog_base.py:12
from browser_use.browser.session import BrowserSession

The session depends on the watchdogs right back, but it cannot say so at the top of the file without a circular import at load time. So all 15 imports sit inside a method body instead:

# browser_use/browser/session.py:1688
async def attach_all_watchdogs(self) -> None:
    """Initialize and attach all watchdogs with explicit handler registration."""
    ...
    from browser_use.browser.watchdogs.aboutblank_watchdog import AboutBlankWatchdog
    from browser_use.browser.watchdogs.captcha_watchdog import CaptchaWatchdog
    ...

What it costs. The browser layer's real dependency structure can no longer be read from the imports at the top of session.py, so both a human and any static analysis tool get a false picture of what depends on what. Neither side can be used alone, because the base class for every watchdog pulls in the entire session module, which is why testing a watchdog means constructing a BrowserSession first. And the direction is backwards from the design intent: watchdogs are meant to observe the session through events, yet BaseWatchdog has a load-time dependency on the very thing it observes.

Part 5: GRASP

Applied well 1 — Polymorphism and Protected Variations: BaseChatModel

# browser_use/llm/base.py:33
class BaseChatModel(Protocol):
    model: str

    @property
    def provider(self) -> str: ...

    async def ainvoke(self, messages: list[BaseMessage], output_format: type[T], **kwargs) -> ChatInvokeCompletion[T]: ...

Fourteen provider packages under browser_use/llm/ implement this one interface, and Agent calls self.llm.ainvoke(...) without ever branching on which company answered. The variation point, "whose API are we talking to," sits behind a stable interface, so adding OrcaRouter in PR #5451 meant adding a folder and changing nothing in agent/. I exercised this myself while getting the project running, switching from browser-use's own service to DeepSeek to Gemini by changing one line.

Applied well 2 — Indirection and Low Coupling: the event bus between Tools and the watchdogs

# browser_use/tools/service.py:731
event = browser_session.event_bus.dispatch(ClickElementEvent(node=node))
click_metadata = await event.event_result(raise_if_any=True, raise_if_none=False)
# browser_use/browser/watchdogs/default_action_watchdog.py:337
async def on_ClickElementEvent(self, event: ClickElementEvent) -> dict | None:
    """Handle click request with CDP. Automatically waits for file downloads if triggered."""

The action layer never names the class that performs the click. The bus sits between them and matches them on the event's name, which is Indirection producing Low Coupling. The payoff is concrete: another subscriber to ClickElementEvent, say click logging, can be added without editing either file.

Violated — Information Expert: Agent decides what a model can do

# browser_use/agent/service.py:474
# TODO: move this logic to the LLMs
# Handle users trying to use use_vision=True with DeepSeek models
if 'deepseek' in self.llm.model.lower():
    self.logger.warning('DeepSeek models do not support use_vision=True yet. Setting use_vision=False for now...')
    self.settings.use_vision = False

Whether a model can read screenshots is a fact about the model, so the model class should own it. Instead Agent substring-matches the model's name, and the project's own comment on line 474 agrees that this belongs in the LLMs. The consequences are concrete: every new text-only model needs an edit to Agent; a model missing from the list gets sent screenshots it cannot use; and the match is wrong in the other direction too, since a future DeepSeek model that can see images would still have screenshots switched off because its name contains "deepseek". My domain model already treats this as a property of the model, AIModel.canSeeScreenshots, which is exactly where the code should put it.

⚠️ **GitHub.com Fallback** ⚠️