Let Your AI Agent Browse the Web with AgentCore Browser
Some information only exists as a rendered page. This is the last part of the series: a real Chrome running in AWS, driven from your laptop — and the three ways the agent got it wrong before it got it right.
Part 8 gave the agent a sandbox to compute in. It works beautifully on data you hand it.
But a lot of what people want from an agent is not in a file you own. It is on a page: a status dashboard with no API, an internal tool that predates anyone caring about integrations, a supplier's stock list. For that the agent needs somewhere to go.
This is the last part of the series, and it is the one where the AWS service worked perfectly and the agent still got the wrong answer three times. Both halves of that are worth your time.
"Why not just download the page?"
It is the right question. Renting a whole Chrome in AWS is a heavy answer, and urllib is free. So rather than assert the answer, here is the measurement — the same URL fetched both ways:
HTTP GET AgentCore Browser
------------ --------------------
bytes received 5806 -
quotes found 0 10
readable characters 96 1495
The target is quotes.toscrape.com/js/, a practice site that exists to be automated, and whose content is built by JavaScript after the HTML arrives.
Look at what happened. The download succeeded — 5806 bytes, HTTP 200, no error anywhere — and it contains none of the page. Ninety-six readable characters of chrome and footer. The browser ran the page's JavaScript and found ten quotes.
This is the failure mode that makes people distrust agents. Nothing errored. The tool reported success. The agent then reasons confidently about a page it never saw.
A tool that reads HTML reads the wrong thing.
What AgentCore Browser is
A real Chrome, running in a container in AWS, that you drive remotely.
StartBrowserSession hands you back a WebSocket URL and a set of signed headers. From that point you are speaking the Chrome DevTools Protocol to a browser somewhere in AWS. Playwright speaks CDP, so Playwright is the client:
# src/browser_agent/browser.py
async def start(self) -> None:
self.client = BrowserClient(self.region)
self.session_id = self.client.start(
identifier=self.identifier,
session_timeout_seconds=self.session_timeout_seconds,
)
ws_url, headers = self.client.generate_ws_headers()
self._playwright = await async_playwright().start()
self._browser = await self._playwright.chromium.connect_over_cdp(ws_url, headers=headers)
# The session arrives with a context and a page already open.
context = self._browser.contexts[0]
self._page = context.pages[0] if context.pages else await context.new_page()
Three things people trip on:
playwright install is not needed. connect_over_cdp attaches to a browser that already exists. It does not launch one and does not download one. There is no Chrome on your machine anywhere in this.
The browser is already open. You do not create a context or a page — you take contexts[0] and pages[0].
Everything is async. Strands runs tools inside an event loop, and Playwright's sync API refuses to run there. Use async_playwright and make the tools async def.
Again, there is nothing to create
As in Part 8, the built-in resource already exists:
SYSTEM_BROWSER = "aws.browser.v1"
Everything in this project's terraform/ directory is optional. We will come back to the one thing it buys you.
Four tools, not one
Part 8 got the tool count down to one and made a virtue of it. Doing the same here — a single run_playwright(code) tool — would be the wrong shape, and it is worth being precise about why.
A sandbox is stateless from the model's point of view: send code, get output. A browser is somewhere the agent is. It has a current page, a scroll position, cookies, history. What an action does depends on where you already are, so the model has to take one step, look at the result, and decide the next.
So: navigate, read_page, click, current_url.
read_page is the one people leave out, and then wonder why the agent hallucinates. The model cannot see the screen. The page text is the only thing it knows:
@tool
async def read_page() -> str:
"""Return the visible text of the current page.
Call this after navigating or clicking. It is the only way to find out what
is actually on the page - do not assume.
"""
Note also that every tool returns its failures as a string beginning with Error: rather than raising. A raised exception ends the run; a returned error goes back to the model, which can read it and try something else. There is a test for each one.
Three things that went wrong
Here is the honest part. The browser service did its job on every single run. The agent still produced a wrong answer three times, for three different reasons.
The question was: which author appears most often across the first three pages of quotes, and how many quotes do they have? The true answer is Albert Einstein, 6.
1. The page and the accessibility tree disagree
The agent read page one, then tried to click through and failed:
3. click(text='Next →')
-> Error: could not click 'Next →' - Locator.click: Timeout 10000ms exceeded.
The pagination link is this:
<a href="/js/page/2/">Next <span aria-hidden="true">→</span></a>
A model reading the rendered page sees Next → and asks to click exactly that. But the arrow is aria-hidden, so the element's accessible name is just "Next" — and a role-plus-name lookup for "Next →" matches nothing at all. The page says one thing and the accessibility tree says another, and Playwright locators live in the accessibility tree.
Worse than the failure was what the agent did next: it gave up on pagination and answered the three-page question from one page.
The fix is in the tool, and it has two halves. Try more than one strategy:
stripped = text.strip().rstrip("→>»›-– \t")
candidates = [
self.page.get_by_role("link", name=text).or_(self.page.get_by_role("button", name=text)),
self.page.get_by_text(text, exact=False),
]
if stripped and stripped != text.strip():
candidates.append(
self.page.get_by_role("link", name=stripped).or_(
self.page.get_by_role("button", name=stripped)
)
)
And when all of them miss, tell the model what is actually there:
raise LookupError(
f"nothing matching {text!r}. Links on this page: "
+ ", ".join(repr(link) for link in await self.links())
)
An error message that lists the real options turns the next attempt into a corrected click instead of the same click again. Design your tool failures for the reader, and the reader here is a model.
2. The model could not add
With clicking fixed, the agent navigated all three pages, read each one, and listed the authors on each page perfectly — Einstein three times on page one, once on page two, twice on page three.
Then it answered: five.
3 + 1 + 2 = 5. The data was right and the arithmetic was wrong.
This is Part 1 of this series, arriving again at the very end. A language model predicting the next token is not an adding machine, and nine parts of infrastructure have not changed that one bit. So it gets an adding machine:
@tool
def tally(items: list[str]) -> str:
"""Count how often each item appears, exactly.
Pass every occurrence as a separate list entry - repeats included - and the
counts come back sorted from most to least common.
"""
counts = Counter(item.strip() for item in items if item and item.strip())
if not counts:
return "Error: no items to count."
lines = [f"{name}: {n}" for name, n in counts.most_common()]
lines.append(f"(total occurrences: {sum(counts.values())})")
return "\n".join(lines)
It is not a browser tool at all. It is in a browser project because that is where the need appeared.
3. It tidied the list
With tally wired in, the counting was exact — and the answer was still wrong. Four, this time.
The tally was correct for what it was given. What it was given was 28 items instead of 30: the model had silently removed duplicate authors while transcribing the list.
The tell was already on screen, because tally reports a total. Three pages of ten quotes must be thirty. So the prompt now says exactly that:
- Pass ONE ENTRY PER ITEM, repeats included. If the same author appears three
times on a page, the list gets that author three times. Do not tidy the list
up or remove duplicates - removing them is the same as miscounting.
- Check the total `tally` reports. This site shows ten quotes per page, so
three pages must total thirty. If the total is lower you dropped repeats;
read the pages again and resend the full list.
And now:
1. navigate(url='https://quotes.toscrape.com/js/')
2. read_page()
3. click(text='Next →') -> Clicked 'Next →'
4. read_page()
5. click(text='Next →') -> Clicked 'Next →'
6. read_page()
7. tally(items=[...30 authors...]) -> Albert Einstein: 6
answer : Albert Einstein appears most often across the first three pages of
quotes, with a total of 6 quotes. The tally confirms that there are
30 quotes in total across the three pages.
Six, from thirty. Correct.
The generalisable lesson is the last one: give the model a way to check its own input, or it will confidently count a list that was already wrong. A tool that returns a total costs you one line and catches an entire class of silent error.
Live view, and taking the controls
This is the feature with no equivalent in Part 8. A sandbox either worked or it did not. A browser can be sitting on a cookie banner that the model does not recognise, doing nothing, for ten minutes.
def live_view_url(self, expires: int = 300) -> str:
return self.client.generate_live_view_url(expires=expires)
Open that while the agent runs and you watch the pages turn in real time. BrowserClient.take_control() hands you the mouse and keyboard; release_control() gives them back to the agent. That is your escape hatch for the login or the consent dialog that no amount of prompting will get past.
The URL is signed and expires in five minutes, but it is still a credential. Do not paste it into a ticket, a screenshot, or a blog post.
The one reason to declare your own browser
aws.browser.v1 handles everything above. What it cannot do is record.
Live view is real-time and gone the moment it ends. A custom browser writes every DOM change, click, console message and network event to a bucket you own, and the console will replay it afterwards. That is the difference between watching an agent fail and being able to work out why it failed last Tuesday.
resource "aws_bedrockagentcore_browser" "recorded" {
name = var.browser_name
execution_role_arn = aws_iam_role.browser.arn
network_configuration {
network_mode = "PUBLIC"
}
recording {
enabled = true
s3_location {
bucket = aws_s3_bucket.recordings.id
prefix = "browser-recordings/"
}
}
}
The browser needs its own execution role, because the browser is what writes into your bucket — not you. The trust policy carries the usual confused-deputy guards:
condition {
test = "StringEquals"
variable = "aws:SourceAccount"
values = [data.aws_caller_identity.current.account_id]
}
And the bucket holds a frame-by-frame record of everything the agent saw, so public access block, encryption and a 30-day expiry are not decoration. A recording is useful for a week and a liability for a year.
cd terraform && terraform init && terraform apply
eval "$(terraform output -raw use_recorded_browser)"
Two permissions that are easy to miss
bedrock-agentcore:ConnectBrowserAutomationStream
bedrock-agentcore:ConnectBrowserLiveViewStream
Without them the session starts happily and then Playwright cannot attach. That presents as a puzzling timeout rather than an access-denied error, which sends you looking in entirely the wrong place.
Cost, and the hour-long default
A browser session bills while it is open, and the SDK's default session timeout is 3600 seconds. An hour of forgotten Chrome is a genuinely surprising line on a bill.
session_timeout_seconds: int = 600,
Ten minutes, plus an async with that stops the session even when the body raises. Neither of those is optional politeness — they are the difference between a demo and something you would leave running.
What did we learn?
- A successful download is not a page. 5806 bytes, HTTP 200, zero content. When a page renders in JavaScript, an HTTP client fails silently and the agent reasons about nothing.
- You attach to a browser, you do not launch one.
connect_over_cdpover a signed WebSocket. No local Chrome, noplaywright install. - A browser is a place, not a function. State makes it different from Part 8's sandbox, and that is why it gets several small tools instead of one big one — and why
read_pageis the tool you must not skip. - Design tool failures for a model. An error that lists the links actually on the page produces a corrected click. An error that says "timeout" produces the same click again.
- The rendered page and the accessibility tree are different documents.
aria-hiddencontent is visible to the reader and invisible to the locator. - Nine parts in, the model still cannot add. The browser was flawless; 3 + 1
- 2 came back as 5. Give it a counting tool, and make that tool report a total so a truncated input is visible.
- Nothing to create, unless you want recording.
aws.browser.v1covers the work; a custom browser plus an S3 bucket buys you replay. - Watch it, and be able to take over. Live view and
take_control()are the answer to the cookie banner that no prompt will solve.
The end of the series
Nine parts ago we deployed a Python function that could add two numbers. Along the way: MCP servers, gateways, OAuth, memory, tracing, sandboxes, and now a browser.
If one thread runs through all of it, it is the one this final part happened to demonstrate three times in a row. The infrastructure is the part you can make reliable. Every AWS service in this series did exactly what it said it would. Every wrong answer came from the reasoning on top — and the fix was almost never a bigger model or a better service. It was a tool that does the thing the model cannot do, an error message written for the model to read, and a way for it to check its own work.
Build the boring, checkable scaffolding. That is the job.