Writing · · 5 min read

When an app has no API, drive the browser you're already logged into

Pointing a coding agent at my real Chrome, and what bites you

aiagentsautomationbrowser-automationdeveloper-experience

Most of the software I use every day has no write API. No "add item" endpoint, no OAuth app, no integrations tab. If I want to get 133 rows into it, the vendor's official answer is that I should type 133 rows into it.

Nah.

Every one of those apps has a UI, and I'm already logged into it. That's the automation surface. So I gave a coding agent a way to drive my actual Chrome — the real one, with my real sessions — and it's turned into one of the more useful things in my setup.

The setup is dumber than it sounds

I keep a Chrome running with --remote-debugging-port=9222 and a --user-data-dir I never throw away. A script connects to that port over the Chrome DevTools Protocol, finds the tab whose URL matches what it wants, and runs JavaScript in the page.

That's the whole thing. It's like fifteen lines.

The trick is the persistent profile. It's a browser I've used normally for months, so I'm already signed in to everything. No OAuth dance, no session cookie lifted from one place and replayed somewhere else, no headless login tripping a bot check at 2am.

That last part is why I like this more than regular headless automation. Auth is the hard part of driving a browser, and it keeps getting harder — 2FA, passkeys, device checks, "we noticed a new sign-in." Headless spends most of its complexity budget losing that fight. A browser that's already authenticated skips the fight entirely. Nothing is stolen or faked. It's my session, in my browser, on my machine.

I wrapped it in a skill so an agent can pick it up and already know the house rules. Those rules are the actual point of this post, because they all came from getting burned.

1. The page remembers what you did to it

Every action leaves the DOM in some state, and your next action starts from that. Obvious written down. Completely forgotten in practice.

Here's what got me: CDP's text insertion appends. It does not replace. So a failed search leaves its query sitting in the box, the next attempt searches for both strings mashed together, fails, and now there are three. One early miss quietly poisons everything after it, and you burn an hour blaming the app's database for not having your stuff.

Clearing the field has its own trap, because on a React input, setting .value directly does nothing React ever hears about:

JavaScript
const setter = Object.getOwnPropertyDescriptor(
  window.HTMLInputElement.prototype,
  'value',
).set;
setter.call(input, '');
input.dispatchEvent(new Event('input', { bubbles: true }));

Reset before every action. Don't assume the box is empty just because you didn't put anything in it.

2. Every match is ambiguous

You're matching text the app rendered for humans, so names and addresses and metadata all come out as one blob with no separator. Match on a loose substring and you'll happily click the row whose address contains your term instead of the one whose name does.

Anchor your patterns. Require a second signal. Keep an exclusion list, because searching a common word surfaces every business named after it.

The one that actually got me was subtler: I asked for a place in one town and got the same chain's location an hour up the road. Both real, both plausible, and nothing about the string was wrong. I only caught it because the tool prints what it resolved and I bothered to read it.

3. Build the dry run first

Resolve the target, print exactly what you'd click, then don't click it.

It's maybe twenty minutes of work and it's the difference between an automation you'll trust with a hundred items and one you won't. I run every new query shape dry before running it for real — not every query, every shape. A human skimming twenty resolved results spots the wrong one instantly, which no pattern of mine ever would have.

4. Scope your fallbacks or don't write them

I once wrote a "click the confirm dialog if one shows up" fallback that scanned the whole page for a button reading Delete or Confirm.

The app had no confirm dialog. So that thing was searching a page full of items for a Delete button and clicking whatever it found first — which is another item's delete button. It reported success. It got lucky that run. Ten more iterations and it would've quietly eaten a neighbor.

Scoping the click to only fire inside a real [role=dialog] fixed it, and now every run honestly says there's no dialog. The general version: a fallback that silently does nothing 90% of the time looks exactly like a correct one until the day it isn't.

5. Check the page, not your own notes

At the end of a batch, reload and read the real state back out of the app. Not a tally of what your script thinks it did.

And know that those little verification helpers you're writing are the least-tested code you own. Mine once flagged a missing entry that was completely fine — my filter was stripping any row containing mi to drop distance lines, and the entry's name happened to contain mi. The data was good. My check was broken. Consider that before you go hunting for a bug.

Other gotchas

  • Keep the debug port on localhost. It's a remote-control channel into a browser holding every account you own. That's the tradeoff for skipping the login problem, and it's fine, as long as it stays on your machine.
  • Nothing you learn about the DOM is permanent. The app ships a redesign and your selectors are fiction. Expect to re-derive them, and fail loudly rather than clicking something adjacent.
  • A hung page hangs your script. If an action wedges the tab, the protocol call can just sit there forever. Time things out.

The trade is worth it when the volume is high and the app is a wall. A few hundred entries went in this way, verified by reading them back out of the page. None of it was possible through an API, because there isn't one.

More writing