I got tired of coding agents telling me the frontend work was done when nobody had opened the frontend.
You’ve probably seen the routine. The agent changes some code, runs the formatter, the type checker, and the tests, everything comes back green, and you get some version of “Implemented and verified.”
But verified how, exactly? A passing test suite is useful evidence, but it isn’t the same as opening the app and using the thing that just changed.
I’ve had plenty of changes where the code made sense and the tests passed, and the UI still had the kind of problem a person spots in about three seconds. A control overlapping something else. A selected state that exists in the store but isn’t highlighted on screen. Reset working for one path and not another. A filter that updates state but leaves the old data on the page. Save looking fine until you refresh. A map layer drawn in the wrong order. A modal that technically opens but doesn’t fit on the screen.
The agent had tested its code, but nobody had tested the application. So I started making browser review part of the definition of done for anything user-facing, and a few weeks in, that habit turned into a tool. I’ll get to that near the end.
In this article, you’ll learn how to give a coding agent a real browser with Playwright MCP, and how to write the instructions that make it test the change it just made instead of confirming that a page loads.
You’ll see how to have the agent start your app the way your project already starts it, how to split those instructions between AGENTS.md and a reusable skill, why a separate reviewer agent catches bugs the author misses, and how to make the agent clean up only what it started.
In the last part, I’ll walk through katalepsis, the open source tool this turned into, including how it decides whether a check really passed.
Table of Contents
- Prerequisites
- The Loop Agents Are Missing
- Why a Browser on Its Own Falls Short
- How to Start the Real App
- Where the Instructions Live
- Why the Reviewer Should Work Independently
- Clean Up After Yourself and Only Yourself
- When a Skill Stopped Being Enough
- How to Try katalepsis
- How katalepsis Works Under the Hood
- What Happened When I Used It
- Where I Landed
Prerequisites
This isn’t a beginner tutorial, so it helps to have:
- Some experience with a coding agent that supports MCP (Model Context Protocol), such as Claude Code, Codex, or Cursor, and an idea of how you give it instructions.
- A web app you can run locally with a dev script, written in JavaScript or TypeScript. The examples use a Node project with
pnpm, but nothing depends on a specific framework. - Basic Playwright knowledge: what a test, a locator, and an assertion look like. You don’t need an existing test suite to follow the first half.
- Node.js 20 or newer for Playwright MCP, and Node.js 22.12 or newer plus Git if you want to install katalepsis.
- Comfort with the terminal and Git, since the agent will be starting processes and checking
git status.
The Loop Agents Are Missing
Coding agents got useful quickly in large part because they already have a decent feedback loop for code. They read, edit, and run the formatter and tests, read the failure, and try again. Nobody has to get it right in one heroic attempt.
For frontend work, the clear missing input is the frontend itself. The loop I want goes a bit further: edit the code, run the tests, start the app, use the changed feature, look at what happened, fix whatever’s broken, and then use it again.
That last step matters more than you might think. Agents are very comfortable telling you a fix “should now work”, and I’d rather they just open the page again and find out.
Playwright MCP is a good way to give them that. I already used Playwright for E2E tests, but the MCP server is useful for a different reason: it hands the agent a browser directly. It can open localhost, read the page, find controls, click and type, check what changed, and take screenshots.
That isn’t the same as asking the agent to run pnpm playwright test. The test suite tells you whether the scenarios you already wrote down still pass. Browser review asks what this particular change could have affected and goes to look.
If an agent refactors some shared map-layer utilities, the tests might cover a few helpers. But from the diff, it can also work out that the code runs during initial render, selection, filtering, and reset, and try each of those. That’s a lot closer to how I review my own frontend work.
Setup is small. You need Node 20 or newer and an MCP-capable client:
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest"]
}
}
}
A quick sanity check is to ask the agent to open the Playwright TodoMVC demo, add a todo called “Browser QA,” and confirm it shows up. If it manages that, it has a browser. What it does with one is the harder part.
Why a Browser on Its Own Falls Short
Giving an agent a browser only gives it the ability to look. It doesn’t tell the agent what to look at, how far to go, or what counts as proof, and without that it falls back to the cheapest thing that technically involves a browser. An agent with Playwright can still do hilariously weak QA. I’ve had reports like this:
Opened /route.
Page loaded successfully.
Browser verification complete.
That’s a report about opening a page, not about testing a change. Nothing in it would have caught any of the bugs from the start of this article.
So the browser needed instructions to go with it. What follows are the rules I wrote for how an agent should review its own frontend work: what to test, what to check, and what evidence to bring back. They started as a few lines I kept repeating in prompts, and they later became the browser-review skill you’ll see in the next sections.
The first rule I settled on is that the review has to come from the change. If it changed a filter, use the filter. If it changed reset logic, change some state first and then reset it. If it touched persistence, refresh the page. If it changed a modal, open and close the modal, and if it changed responsive layout, resize the viewport.
That all sounds obvious when a person says it, but it isn’t obvious at all if your agent instructions just say “verify that the app works”.
In practice, this means the agent reads the diff and turns it into a short test plan before it touches the browser. For a change to a filter bar, the plan I’d expect looks something like this:
Changed: src/features/projects/FilterBar.tsx, src/features/projects/useFilters.ts
Plan:
1. Open /projects and note how many rows are showing.
2. Filter by Status: Active. Every row should say Active.
3. Add Owner: me on top. The list should narrow again.
4. Clear filters. The original row count should come back.
5. Apply a filter and refresh. The filter lives in the URL, so it should survive.
Then it carries out that plan through the browser tools Playwright MCP exposes. A trimmed version of the calls for the first few steps looks like this:
browser_navigate { "url": "http://localhost:5173/projects" }
browser_snapshot {} → table with 40 rows
browser_click { "element": "Status filter", "ref": "e14" }
browser_click { "element": "Active option", "ref": "e31" }
browser_snapshot {} → table with 12 rows, all "Active"
browser_click { "element": "Clear filters", "ref": "e15" }
browser_snapshot {} → table with 40 rows
The ref values come from the accessibility snapshot, which is how the agent finds a control without guessing at CSS selectors.
This paid off on a PR that fixed a cursor getting stuck in its hover state on an interactive map. The PR’s new unit tests passed against the old code too, so they didn’t prove the fix did anything. What did prove it was the agent opening the app, moving on and off the interactive layers, and watching the cursor change and reset exactly when it should.
The second rule is to check the end result rather than the interaction. “I clicked Reset and nothing threw” doesn’t tell you much. What you actually care about is that reset puts things back the way they were. Same with saving: the real claim is that you can change a setting, save, refresh, and it’s still there.
Written as a Playwright test, that claim looks like this. The important line is the last one, which runs after the reload:
import { test, expect } from "@playwright/test";
test("weekly digest setting survives a reload", async ({ page }) => {
await page.goto("/settings/notifications");
await page.getByRole("switch", { name: "Weekly digest" }).click();
await page.getByRole("button", { name: "Save" }).click();
await page.reload();
await expect(page.getByRole("switch", { name: "Weekly digest" })).toBeChecked();
});
The agent doesn’t need to write this test to follow the rule. Through MCP it does the same thing by hand: click, save, browser_navigate to the same URL, and read the switch’s state from a fresh snapshot.
The third rule is to match the evidence to the question. Whether the URL changed, a button is disabled, or a dialog opened is a question for the DOM and browser state, not a screenshot. Whether that dialog is clipped, two things overlap, or text wraps badly on a phone is a visual question, and that’s where screenshots are useful. Sometimes you need both, since a modal can sit happily in the DOM while being completely unusable on screen.