Letting agents drive a real browser on the Mac I'm typing on
Headless Chrome walled over one User-Agent token, macOS dragging 'off-screen' windows back into view, minimized popups that steal your keyboard, and sign-in windows that leaked for days.
I was halfway through a sentence in my terminal when the rest of it went somewhere else. No window had appeared. Nothing on screen had changed. My keystrokes were simply going into a Chrome window I couldn't see, opened by one of my own agents.
That is the strange position you end up in when your agents run on the laptop you're working on. Mine run all day: hourly fleet runs, scheduled jobs, and a reviewer that asks a frontier model for a second opinion in a browser tab. Plenty of those steps need a real, logged-in browser rather than an HTTP client, which means a real Chrome, with a real profile, on my real desktop.
So the problem has two sides. The agent needs a browser that looks like a person's: real profile, real sessions, real user agent. I need it to never show up on my screen and never take my keyboard. Here is what that took, in the order things broke.
1. Headless got walled over one word
Headless Chrome is the obvious way to stay off the screen. On chatgpt.com it sat forever on Cloudflare's "Just a moment..." page.
The usual advice at this point is a stealth stack: fingerprint patches, simulated mouse movement, everything people do to look human. None of it was needed. Headless Chrome puts the token HeadlessChrome in its User-Agent header. That's it. With the same profile, still headless, I sent the exact User-Agent that headed Chrome sends (major version read from the installed binary, reduced to major.0.0.0 the way Chrome itself reduces it), and the challenge page turned into the logged-in composer.
Before you build a stealth stack, diff what your headless browser sends against what a normal one sends. The difference that matters is often the dullest one.
2. macOS will not let you hide a window off-screen
A few flows refuse headless entirely, so I also needed a headed Chrome that is never visible.
My first idea was the classic one: --window-position=-10000,-10000. Put the window somewhere no screen is. macOS doesn't allow that. A window that sits on no display gets clamped onto the nearest one, and my external monitor sits to the left of the laptop. The "hidden" window landed in full view, on the bigger screen, and Chrome un-hid itself on every new page for good measure.
Hiding a window 10,000 pixels to the left, on a desk where the second monitor is on the left. In hindsight.
What works:
- Start Chrome with no window at all (
open -j -gand--no-startup-window). - Create every page through the DevTools protocol,
Target.createTargetwithwindowState: "minimized",background: true,focus: false. Playwright'scontext.new_page()opens a normal window that can only be minimized after it has been shown, which is a visible flash every time. - The page still reports
visibilityState: "visible", so sites that throttle background tabs load normally.
3. Hidden pixels are not hidden focus
This was the sentence-eating bug from the top. A minimized window can still take keyboard activation when it's created, and an OAuth popup does exactly that. I would be typing into my terminal and the keystrokes went to Chrome.
The fix is a small focus guard: check the frontmost process every 2 seconds, and hide Chrome only when Chrome is frontmost. Both halves of that rule came from bugs I shipped first:
- Hiding Chrome when it wasn't frontmost reshuffled the macOS activation stack and bounced me into some unrelated app.
- Re-activating "whatever app was frontmost before" sounds right, but that memory goes stale fast.
I didn't trust it until I measured it: sample the on-screen window list every 50 ms while ChatGPT loads. Zero on-screen windows, and Chrome never took the foreground.
4. Sign-in windows leaked for days
Sign-in windows piled up in the Dock for days before I noticed. There were two causes:
- When an agent attaches to a long-lived Chrome it doesn't own,
close()tore down nothing at all. - An OAuth popup is opened by the site itself via
window.open, so it never passes through the caller's code.
Now every page created while attached is tracked, identity-provider pages are closed on teardown, and the shared browser lock sweeps leftover auth popups when it's released. That last part catches jobs that were killed and never got to run their own teardown.
Two traps while writing that cleanup, in case you're about to write one:
- For a page Playwright didn't create,
page.urlstays''forever, andframenavigatednever fires. Readlocation.hrefthroughevaluateinstead. - A Google sign-in popup on an already-signed-in profile doesn't stay on
accounts.google.com. It lands onmyaccount.google.com, so both hosts have to count as auth popups.
A reaper (launchd, every 120 s) also closes any headless or hidden Chrome that has had no DevTools client for 5 minutes.
5. Three modes, and one honest way to ask for help
All of that ended up as three modes behind one call:
| Mode | Used for | On screen |
|---|---|---|
| headless | everything that works headless | never |
| hidden | sites that refuse headless | no: minimized, focus-guarded |
| handover | a blocker only I can clear (login, 2FA, CAPTCHA) | yes |
There is one Chrome per profile. A hidden caller evicts a headless one; a handover window is protected for 30 minutes and is never reaped while I'm using it. An agent that hits a CAPTCHA doesn't guess or retry. It opens a handover window and files a blocker for me.
Arrows point at the mode that gets closed. Orange marks the one mode that puts a window on my screen.
6. And then an agent did it anyway
I should say how much I care about this. Of everything an agent can do wrong on my machine, interrupting me ranks near the top, above being slow and above being wrong in a way I can fix later. You would think that after all this I'd be done with agents taking over my screen. This week an agent working in my own everyday Chrome (not the automation one) hit a page that renders nothing at all while its window is minimized. It wanted the page, so it un-minimized the window. Focus gone, mid-sentence, again.
None of the machinery above was in play, because that browser isn't the automation browser. It is mine. The rule that would have stopped it is the one from section 5: when the only way forward is something visible, that's a handover to me, never a call the agent makes on its own. It's now written down as exactly that, and that step is waiting for me.
What I'd tell someone building this
- Most "bot detection" failures I hit were one header or one window flag, found by diffing against a normal browser.
- Test invisibility by measuring the window list. Looking at your screen and seeing nothing proves very little.
- Anything an agent opens, it also closes, including pages it didn't open itself.
- Give the agent a clean way to ask a human, and make it the only way past anything visible. A blocker it can file beats a CAPTCHA it tries to solve, and beats a window it decides to show you.
Thanks for reading. Next up: why my agents got more reliable once I took some steps away from them and turned those into plain scripts. Follow on RSS or X @denzilnelsonai. If you run agents on your own machine, I'd like to hear what they've done to your screen.