Browser Sub-Agent Practical Guide — AI Autonomously Operating the Web
How to hand nightly browser automation to an Antigravity agent safely: a read/mutate/irreversible permission model, plus a measured rate-limit comparison that cut 429s from 147 to single digits.
Google Antigravity IDE's Browser Sub-Agent gives your AI agent "hands" to operate the web. Unlike traditional web scraping or test automation tools, this AI understands natural language and controls browsers like humans would—clicking, typing, scrolling, and waiting.
Key capabilities:
Web Scraping: Extract data from JavaScript-heavy sites (not just static HTML)
Form Automation: Handle multi-step authentication, complex forms, conditional UI elements
E2E Testing: Run user scenarios automatically, capture screenshots, verify outcomes
Business Automation: Price monitoring, report generation, daily workflow execution
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦The real auto-approval risk of headless runs, and how allow-rule design tames it
✦A three-tier lens for browser actions: read, mutate, irreversible
✦A measured rate-limit comparison that cut 429s from 147 to single digits
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
class E2ETestRunner { async testCheckoutFlow(baseUrl: string) { const browser = await launch(); const page = await browser.newPage(); try { // Scenario 1: Search for product console.log('Test 1: Search for product'); await page.goto(`${baseUrl}/`); await page.type('#search-input', 'AI notebook'); await page.click('#search-button'); await page.waitForSelector('.product-grid', { timeout: 10000 }); // Scenario 2: Navigate to product detail console.log('Test 2: Navigate to product detail'); await page.click('a.product-link:first-child'); await page.waitForSelector('.product-detail', { timeout: 5000 }); // Scenario 3: Add to cart console.log('Test 3: Add to cart'); await page.click('#add-to-cart-button'); const cartBadge = await page.$eval( '.cart-count', (el) => el.textContent ); console.assert(cartBadge === '1', 'Cart should have 1 item'); // Scenario 4: Proceed to checkout console.log('Test 4: Proceed to checkout'); await page.click('a[href="/checkout"]'); await page.waitForSelector('#shipping-form', { timeout: 5000 }); // Scenario 5: Fill shipping info console.log('Test 5: Fill shipping info'); await page.type('#address', '123 Tech Street, San Francisco'); await page.select('#country', 'US'); // Scenario 6: Payment info (use Stripe test keys in production) console.log('Test 6: Fill payment info'); const stripeFrame = page.frames().find((f) => f.url().includes('stripe.com') ); if (stripeFrame) { await stripeFrame.type('[placeholder="Card number"]', 'YOUR_TEST_CARD_NUM'); } // Scenario 7: Complete order console.log('Test 7: Complete order'); await Promise.all([ page.waitForNavigation({ waitUntil: 'networkidle0' }), page.click('#place-order-button'), ]); // Confirmation screenshot const screenshot = await page.screenshot({ path: './test-results/order-confirmation.png', }); console.log('✓ Checkout flow completed successfully'); return true; } catch (error) { console.error('✗ Test failed:', error); await page.screenshot({ path: './test-results/error-screenshot.png' }); return false; } finally { await browser.close(); } }}// Run testsconst runner = new E2ETestRunner();runner.testCheckoutFlow('https://example-ecommerce.com') .then((success) => process.exit(success ? 0 : 1));
Production Security Design
Secure Credential Management
The first thing to control in browser automation is how long a credential stays readable in memory. You'll often see a "secure wipe" written like this:
// ⚠️ Looks careful. Erases nothing.try { return await this.executeTask(task, decrypted);} finally { // Strings are immutable — reassigning only swaps the reference decrypted.password = Buffer.alloc(decrypted.password.length, "\0");}
JavaScript strings are immutable. Assigning a new value rebinds the variable; the original string stays on the heap until the collector gets to it. Anyone who can take a heap dump in that window can read it. Believing you wiped it is worse than knowing you didn't.
If you intend to erase the secret, keep it in a Buffer from the moment it's decrypted.
type Secret = { user: string; password: Buffer };class SecureAutomation { constructor(private vault: CredentialsManager) {} async runWithCredentials<T>( task: string, fn: (secret: Secret) => Promise<T>, ): Promise<T> { // Decrypt straight into a Buffer — never into a string const secret = await this.vault.decryptToBuffer(task); try { return await fn(secret); } finally { // Buffers are mutable: this really does overwrite the bytes secret.password.fill(0); } }}// Callers pass the Buffer around and stringify only at the last momentawait automation.runWithCredentials("nightly-check", async (secret) => { await page.type("#password", secret.password.toString("utf8")); return page.click("#submit");});
Calling toString() still creates an immutable string, so this isn't perfect erasure. But narrowing the exposure to a single keystroke operation changes the blast radius considerably when something does go wrong. Short lifetime beats the illusion of a clean wipe.
Not Breaking the Rate Limit on the Other Side
The next thing that bites in unattended runs is request pacing. I run my own apps and sites on Cloudflare, so I see this from both ends — not just as the client sending traffic, but as the server returning 429s. From the receiving side, careless automation doesn't look fast. It looks like a visitor who keeps re-sending the same request.
So I set up a stub server whose limit moves, and measured against it. It accepts 8 requests per second normally, tightens to 3 per second between the 4- and 9-second marks, and answers anything over the line with a 429 carrying Retry-After: 1. A ceiling that shifts under load is common behaviour for real APIs.
I compared three families across six conditions: unthrottled burst, two fixed rates, and adaptive rate control (AIMD) at three different coefficient settings, backing off whenever a 429 arrives. Here's the adaptive implementation.
type AimdOptions = { initial: number; min: number; max: number; dec: number; rec: number };async function fetchAllAdaptive(urls: string[], opt: AimdOptions) { let rate = opt.initial; // current send rate (requests/sec) let lastRecover = Date.now(); let throttled = 0; const done: string[] = []; const queue = [...urls]; while (queue.length > 0) { const res = await fetch(queue[0]); if (res.status === 429) { throttled++; // Multiplicative decrease: pull back hard on rejection rate = Math.max(opt.min, rate * opt.dec); lastRecover = Date.now(); const wait = Number(res.headers.get("retry-after") ?? 1); await sleep(wait * 1000); // always honour Retry-After continue; // retry the same URL — don't drop it } done.push(await res.text()); queue.shift(); // Additive increase: creep back up while things stay healthy const elapsed = (Date.now() - lastRecover) / 1000; if (elapsed >= 1) { rate = Math.min(opt.max, rate + opt.rec * elapsed); lastRecover = Date.now(); } await sleep(1000 / rate); } return { done, throttled, finalRate: rate };}
Time to fetch all 45 items, and the number of 429s the server had to issue along the way (median of three runs per condition; run-to-run spread was under 10 ms).
Strategy
Time to finish
429s forced on the server
Unthrottled burst
8.3 s
147
Fixed 5 req/s
13.4 s
3
Fixed 8 req/s
11.1 s
4
AIMD (decrease 0.5, recover 0.5/s)
14.0 s
3
AIMD (decrease 0.7, recover 2.0/s)
10.2 s
4
AIMD (decrease 0.9, recover 2.0/s)
11.6 s
6
The top row is the one worth sitting with. Bursting really is fastest — 8.3 seconds. It also makes the server reject 147 requests to deliver 45 successes: 3.3 wasted requests for every useful one, all of it paid for by someone else's infrastructure. Roughly two seconds of savings is a poor trade for that.
The second result flipped the sign on what I expected. Adaptive control helps or hurts depending entirely on the coefficients. Decrease 0.7 with recovery 2.0/s finished in 10.2 seconds — 0.9 seconds (8%) faster than fixed 8 req/s, at the same four rejections. Drop the decrease factor to 0.5, though, and it falls to 14.0 seconds: the rate never climbs back after the congested window closes, making it the slowest of the six conditions — a 37% swing in completion time from a single coefficient. Loosening to 0.9 recovers the speed but pushes rejections up to six. Adaptive control isn't a mechanism that makes things faster; it's a mechanism that becomes slower than a fixed rate when the coefficients are wrong.
Worth noting the spread: decrease 0.7 / recovery 2.0 was the only condition that varied — two runs at 10.2 s with four rejections, one at 11.2 s with five. Where the congested window falls relative to a request boundary shifts the count by one. Every other condition landed within 10 ms across all three runs.
So what about when you get the ceiling wrong? I re-ran the comparison assuming 12 req/s against a real limit of 8.
Strategy
Time to finish
429s forced on the server
Fixed 12 req/s (over-estimated ceiling)
12.2 s
7
AIMD (initial 12, decrease 0.7, recover 2.0/s)
12.0 s
6
Even under the conditions where adaptive control should shine, the gap was 0.2 seconds and one rejection. The reason became obvious once measured: this server answers with Retry-After: 1, so every stumble costs a full second of waiting. That wait dominates, and fine-tuning the send rate barely registers against it.
The conclusion is simpler than the machinery suggests: almost all of the benefit comes from honouring Retry-After and retrying, not from how cleverly you pace requests. The drop from 147 rejections to a handful is produced by one line — wait as long as you were told to — rather than by any control loop.
If the other side publishes its ceiling, I'd skip adaptive control entirely — the tuning isn't worth it. That suggests an implementation order.
Priority
What to do
Why
First
Read Retry-After, wait, retry the same URL
This is what takes 429s from 147 to single digits
Next
A modest fixed rate (roughly 70% of the published ceiling)
If the limit is documented, adaptive control adds nothing
Last
AIMD (start from decrease 0.7, recover 2.0/s)
For undocumented ceilings. Wrong coefficients make it slower than fixed
The pull toward a 0.5 decrease factor is understandable — erring toward caution feels responsible. In this measurement, that caution bought one fewer rejection at a cost of 3.8 seconds. Knowing that the safe setting has a price, and that the price swings by more than thirty percent on a single coefficient, makes the choice easier to defend.
Why This Guide Skips Detection Evasion
Spoofing headers to look human, hiding navigator.webdriver, rotating source IPs through proxies — none of that appears here. Not because it's technically out of reach, but because I don't build on it.
E2E tests against your own site have nobody to hide from. When another company's site refuses automation, that's a fork in the road toward their official API or an explicit agreement, not a signal to route around the check. Working solo, there's no legal team to renegotiate access after a terms violation costs you an API key or an account. So I ship only designs that hold up without concealment.
Read robots.txt and stay off the paths it excludes. Send a User-Agent that identifies you. Honour Retry-After. It's unglamorous, but meeting those three means you can explain yourself if the other side ever gets in touch.
Shipping apps to the App Store and Google Play year after year taught me that what pays off later is rarely speed — it's whether you can explain what you did. Browser automation has the same shape. Staying explainable is what lets automation keep running for years.
Best Practices
Error Handling is Critical
Handle network failures and timeouts. If a CAPTCHA appears, stop and notify rather than route around it
Implement retry logic and logging
Wait Properly for Content
waitForNavigation(): Page transitions
waitForSelector(): Dynamic content loading
Avoid waitForTimeout() (last resort only)
Prevent Memory Leaks
Properly close browser instances
Clear large Cookie/localStorage
Logging & Debugging
Log each step with timestamps
Screenshots on failures
Designing Autonomy Permissions — Learning from "Silent Auto-Approval"
Once you fold Browser Sub-Agent into nightly automation, one question always surfaces: how much do you delegate to the agent's own judgment?
In July 2026, Antigravity CLI 1.1.3 shipped two fixes around headless execution (-p). Behavior where the agent would hang on tools that need confirmation — or silently auto-approve them — was corrected. It now soft-denies and prints the name of the allow rule required to proceed to stderr. A separate bug, where always-proceed mode wrongly auto-approved writes to files outside the workspace, was closed as well.
What this quietly signals is that "proceed automatically" and "what may proceed automatically" are two different designs. Browser actions make this line especially sharp. When a single form submission can trigger a payment or an account cancellation, a permissive default becomes the entry point for an incident.
I ran into this myself. When I automated an inventory check on the admin panel of a wallpaper app, I initially let every browser action through unconditionally. One night, with the session expired, the agent kept posting empty credentials to the login form, and the account was temporarily locked. Because my logs were thin, it cost me a full day to trace the cause. Since then, I split browser actions into three tiers — read-only, mutate, and irreversible — and always gate the latter two behind an allow rule and a prior confirmation.
The snippet below is a minimal classifier that sits in front of the agent, one layer before execution.
type ActionRisk = "read" | "mutate" | "irreversible";// Classify the action's intent into three tiersfunction classifyAction(action: { type: string; url: string }): ActionRisk { const irreversible = /\/(delete|cancel|withdraw|charge|pay|checkout)/i; const mutate = /(submit|post|put|update|create)/i; if (irreversible.test(action.url)) return "irreversible"; if (mutate.test(action.type)) return "mutate"; return "read";}// Decide whether to auto-approve or require a human checkasync function guard( action: { type: string; url: string }, allowRules: Set<string>,) { const risk = classifyAction(action); if (risk === "read") return true; // reads pass automatically const ruleName = `${risk}:${new URL(action.url).hostname}`; if (!allowRules.has(ruleName)) { // Soft-deny. Name the allow rule and hand the decision back to a human console.error(`[guard] blocked ${risk}. add allow rule: ${ruleName}`); return false; } return true;}
The key is that a denial always prints which allow rule would unblock it. This mirrors how CLI 1.1.3 now surfaces allow-rule names on stderr: it keeps the reason a run stopped legible from the logs the next morning. Leave that vague, and you pay the same triage cost I burned a day on — every single time.
A Framework for Deciding What to Delegate
Once actions are split into three tiers, assign each a default handling and a protection. Here is the table I actually use.
Tier
Example actions
Default handling
Required protection
Read-only
Navigation, text extraction, screenshots
Auto-approve
Rate limiting and logging only
Mutate
Form submission, settings updates, saving drafts
Allow rule required
Per-domain permission + diff logging
Irreversible
Payment, cancellation, deletion, publishing
Human pre-confirmation
Dry run + two-step approval + notification
Studying this table, you notice the hard cases live in the middle — "mutate." Reads and irreversibles are obvious. The ambiguity is in actions like saving a draft: reversible, yet leaving a trace. For those, I decide which tier to fold them into based on how far a failure ripples into money or user experience, and whether a recovery procedure is written down. Holding your judgment axis as "can it be undone?" and "who gets hurt?" rather than a number means new actions never leave you stuck.
Verify First, Then Delegate — A Staged Migration
With permissions designed, don't drop straight into unattended nightly runs. Move in stages. I keep this order:
Attended runs on your machine. Pass every action through a confirmation prompt once, and eyeball whether the classification matches intent.
Set up observability metrics and alerts. Record success rate, duration, and denial count per action type, and fire a notification when denials spike.
Validate in production with a limited rollout. Unattend only read-only actions first; watch the logs for a week before unlocking the mutate tier.
Keep the irreversible tier in human hands to the end. Resisting the temptation to automate this is the one line that lets you keep delegating safely over the long run.
The order looks like a detour, but factoring in recovery cost when something breaks, it was the fastest path in the end.
Conclusion
Browser Sub-Agent is a powerful way to give AI a pair of hands. That is exactly why designing the scope of what you delegate deserves the same energy as making it run. As a concrete next step, classify the actions in an existing automation script into read / mutate / irreversible, and add one allow rule at a time to the latter two.
I am still refining my own design. I hope this helps with your implementation — thank you for reading.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.