Logical Architecture - swisherae/browser-use GitHub Wiki
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.
| 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 pasttools/to importbrowser/anddom/directly, and one real cycle exists betweenBrowserSessionand the watchdogs, which Part 4 documents.
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
-
Every arrow points down a layer, except the two dashed ones labelled "also imports directly".
Those mark where
agent/reaches pasttools/straight intobrowser/anddom/. -
The browser layer is where the style changes.
BrowserSessionand the watchdogs don't call each other directly; they pass events throughbubus.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 atdocs/architecture.png.
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.
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()
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.
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
Finding:
BrowserSession.on_NavigateToUrlEventruns beforeSecurityWatchdog.on_NavigateToUrlEvent, so the page starts loading before the check runs. I confirmed this withallowed_domains=['example.com']: the tab was switched toabout:blankafter 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.
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 BrowserSessionThe 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.
# 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.
# 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.
# 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 = FalseWhether 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.