When an AI agent drives a Playwright browser, every outbound request — the navigation the agent asked for, every asset the resulting page loads, every redirect along the way — passes through the same browser context you already control. Playwright's request-interception API, page.route(), is the enforcement point: resolve a page type for each request's URL and decide route.continue() or route.abort() before Playwright ever sends it. This guide walks through the pattern, an illustrative code sketch, and the edge cases that trip up a first attempt.
A Playwright-driven agent typically has a higher-level agent framework deciding what to do next — navigate here, click that, fill this field — but underneath all of it, Playwright itself is the thing actually issuing HTTP requests. page.route() (or its context-level equivalent, browserContext.route()) intercepts every request matching a URL pattern before Playwright sends it, and lets your handler call route.continue(), route.abort(), or route.fulfill() with a synthetic response.
A mechanism, present in Playwright and comparable browser-automation tools, that lets calling code intercept a request matching a given URL pattern before the browser's network stack sends it, and decide whether it proceeds, is aborted, or is answered with a fake response — without the page or any script running inside it ever knowing interception happened.
Most Playwright-driven agents are built with a higher-level agent framework on top and Playwright itself doing the actual browsing underneath, which means the route handler is a backstop that doesn't care what the top layer decided or how it decided it — only what request is about to go out over the wire.
This makes it structurally different from an agent-framework-level hook: it doesn't matter whether the request came from the agent's own "navigate" action, a redirect the server issued, a link the agent clicked, or a background asset the resulting page loads on its own. If it's a request Playwright's network stack is about to send inside that browser context, the route handler sees it. That is also why it pairs naturally with the tool-wrapper or action-wrapper patterns in our LangChain and browser-use library guides: those catch the agent's decision at the framework level; a route handler catches the resulting network traffic at the browser level, regardless of which framework or none drove the decision.
Call context.route("**/*", handler) once, immediately after creating the browser context and before any page is opened. Registering per-page risks a race where the agent's very first navigation fires before your handler is attached; context-level registration guarantees coverage from the first request onward, including any new tab or popup opened later in the session.
Every intercepted request carries a URL and a resource type (document, script, image, xhr, fetch, and so on). The policy check in step 3 applies to the URL; the resource type tells you whether this is the top-level navigation the agent asked for or a background asset the resulting page is pulling in on its own.
Call the same framework-agnostic check function described in our implementation guide for every request whose resource type is document — these are the actual page navigations, the ones that matter for a login/checkout/upload policy. Resolve the page type against the schema on the page-types database page and compare to your written policy.
Images, stylesheets, and scripts loaded by an already-approved page usually don't need the same page-type check — blocking them can break rendering without adding meaningful protection. The egress rules layer is more relevant here: a script or xhr request matching a known-risky URL pattern (a plugin-install endpoint, a WebDAV write) should still be evaluated even on resource types you otherwise pass through.
Every code path through your handler must call exactly one of Playwright's route-resolution methods. A handler that occasionally falls through without calling any of them leaves that specific request hanging until Playwright's own timeout, which will look like a mysterious agent stall rather than an obvious policy failure — treat an un-called route as a bug, not an edge case.
The route handler is your authoritative log source, since it has the full request in scope. The egress rules and high-value host list catch risky URL shapes and dangerous hosts outside the page-type database's coverage; evaluate both inside the same handler rather than as a separate pass.
This is a conceptual sketch using Playwright's Python sync API. It is illustrative only — verify the exact method names and async/sync variants against the Playwright version you have installed, since the API has evolved across releases.
Two details worth calling out: registering the route on context rather than page means any additional page or popup created later in the same context (see the multi-tab note below) inherits the same handler automatically, and calling route.abort("blockedbyclient") rather than a generic error code gives you a distinguishable reason in Playwright's own network logs when you're debugging a denied request later.
| Mechanism | Can it block the request? | Scope | Best used for |
|---|---|---|---|
page.route() | Yes — continue/abort/fulfill | Single page only | Single-page scripts, not multi-tab agents |
context.route() | Yes — continue/abort/fulfill | Every page and popup in the context | Primary enforcement point for an agent session |
page.on('request') / on('response') | No — observation only | Per page it's attached to | Logging and debugging, not enforcement |
| A separate forward proxy in front of the browser | Yes, at the network layer | Everything the OS process sends | Backstop if a tool bypasses Playwright's own network stack |
The event listeners (on('request'), on('response')) are useful for building the audit trail your team reviews later, but they fire after the fact and cannot stop a request — don't mistake a well-instrumented logging setup for an enforced guardrail. context.route() is the one mechanism in this table that both sees everything in scope and can actually refuse it.
Most route-handler bugs we'd expect to see live in these three cases, each of which behaves slightly differently from a simple top-level navigation.
Redirect chains. Playwright's route interception fires again for the redirected URL, so a policy-checked handler registered at the context level already re-evaluates each hop without extra code — the mistake to avoid is a handler that caches its decision by the original URL rather than the URL on the current invocation, which would let a redirect chain ride through on the first hop's allow.
New tabs and popups. Because the handler in the sketch above is registered on context rather than page, a popup or new tab created inside the same context inherits it automatically — this is the main practical reason to prefer context-level registration over page-level from the start, rather than remembering to re-register on every new page event.
Service workers and background fetches. A page can register a service worker that makes its own requests independent of any user or agent action, and depending on your Playwright version and routing scope, some of this traffic may not flow through the same context-level handler. Treat any gap here as a reason to keep the egress-rule and host-list layers active regardless of route-handler coverage, since they don't depend on the same interception path.
A persisted, reused browser context across sessions. Teams that reuse a saved storage_state to skip repeated logins between agent runs should re-register the route handler on every new context created from that saved state, since storage state carries cookies and local storage, not your interception setup — a context restored from a saved state with no route handler attached is a context with no guardrail at all, however carefully the original session was configured.
A subtlety that catches teams who've only tested against simple pages: context.route() intercepts requests from every frame in every page in the context, including iframes, so the mechanism in the sketch above already covers them without extra registration. What it does not do automatically is apply a different policy to an iframe than to its parent page, and that distinction matters more than it first appears.
An allowed page — say, a vendor's blog post, resolved as page type blog — can embed an iframe pointing at a completely different domain: an ad network, a comment widget, an embedded payment form. Because the route handler evaluates each request's own URL independently, the iframe's source URL gets its own page-type check regardless of what the surrounding page resolved to. This is the correct behavior, not a bug to work around: the parent page being allowed says nothing about whether an embedded third-party surface should be. If your policy wants to treat embedded content more permissively than a top-level navigation to the same domain, that has to be an explicit decision encoded in the check — for instance, by inspecting request.frame() !== request.frame().page().main_frame to distinguish a frame-level request from a top-level one, and applying a separate, more permissive policy where that distinction is genuinely intentional rather than an oversight.
The safer default, and the one we'd recommend absent a specific reason otherwise, is to apply the same policy to frame-level document requests as to top-level ones. An embedded payment iframe is exactly as much a checkout-shaped surface as a top-level navigation to the same checkout page would be, and an agent that can't be trusted to navigate to a checkout page directly shouldn't be able to reach the same functionality one layer down through an iframe either.
Consider an agent driven by Playwright whose job is to periodically visit each vendor's status page and report whether anything shows degraded. The route handler above sees every request in the browser context, so it also sees whatever the status page itself loads — stylesheets, a status-widget script, sometimes a redirect from a bare /status path to a locale-specific one.
The top-level navigation to the status page resolves to the status page type, which the policy allows, and the redirect is re-checked at its own hop and also resolves to status, so the handler continues both. If the page includes a "manage subscription to this status page" link and the agent, trying to be thorough, clicks it, the resulting navigation resolves to a subscribe page type — one of the eight action types this database tracks specifically because agents attempt them — and a default-deny policy aborts it before the request leaves. The agent's monitoring loop continues to the next vendor with nothing more than a denied request in its own log, rather than a subscription action nobody asked for.
Before pointing a route handler like this at a real monitoring fleet, run it once against the 100-domain free sample with a short script that drives Playwright to each domain's known login and status URLs from the CSV and asserts the expected decision for each. This catches the class of bug that's easy to miss in code review — a handler that technically calls route.continue_() and route.abort() correctly but has the allow/deny branches swapped — before it ever runs against a vendor you actually monitor.
Several 2026 incidents involved agents reaching write or credential-shaped endpoints on sites nobody had reviewed in advance — a JFrog Artifactory plugin-install page, an unauthenticated WebDAV endpoint, a wiki's legacy edit URL. A route handler positioned at the browser-context level, evaluating the egress rules against exactly these URL shapes, denies each one before Playwright ever sends the request, regardless of which higher-level agent framework decided to click there.
The 2026 agent incidents, prevented Read the JFrog Artifactory caseThe honest fine print — the same two assumptions we publish, plus two operational ones
The four-layer enforcement model behind every lookup on this site.
The higher-level library pattern most of these agents sit on top of Playwright with.
Centralizing the same check inside a shared MCP fetch/browse tool server.
The reference check function this route handler builds on.
The companion product for blocking human access to AI tools, from the same team.
await route.continue_() or await route.abort() instead of the sync calls in the sketch above. Confirm the exact method names for the Playwright version and language binding you're using (Python, Node, Java, or .NET all expose this API with minor naming differences).page.setRequestInterception plus a request event with request.continue()/request.abort()). The underlying pattern — check before continue, abort before the request leaves — carries over; only the exact method names differ.Download the sample, adapt the sketch above, and confirm a known login URL actually aborts before this touches production traffic.