Albert Oviedo

Chrome Loop Automator: Hostname-Scoped Click Loops with If/Then Plans

How I built a Manifest V3 Chrome extension that runs a repeatable instruction plan on one site, recovers from flaky pages, and pings my iPhone when it finishes.

  • chrome-extension
  • javascript
  • automation
  • manifest-v3

Some web UIs still need a human in the loop: expand a panel, read a status, click Continue, wait, try again if the page is still loading. Doing that for hours is not engineering — it is babysitting a browser tab.

Chrome Loop Automator is a Manifest V3 extension I built for that job. It runs an instruction plan on one hostname you lock in, not on whatever tab happens to be focused. When the plan succeeds — or when it hits a real problem — it can push a notification to my iPhone through ntfy.

The problem in one sentence

I needed a loop that clicks, reads, and branches on page text, without turning into a generic “automate any site” bot.

What it is not

It is not a headless scraper, not Selenium, and not a Chrome Web Store product. It loads as an unpacked extension. The popup is in Spanish because that is how I use it day to day. Credentials stay on the machine: chrome.storage.session by default, chrome.storage.local only if I tick “remember.”

The allowlist is the real control plane. No hostname configured means the loop does not start. Chrome internal pages (chrome:, chrome-extension:, edge:, about:, devtools:) are rejected. Subdomains of the allowed host match; everything else is ignored.

How a loop actually runs

Each pass through the plan looks like this:

  1. Guard the tab — confirm the page hostname still matches the allowlist
  2. Recover the page — dismiss a “session expired” modal, wait-and-reload on maintenance copy, wait-and-reload if the body looks incomplete or stuck
  3. Shortcut success — if a global target phrase is already on the page, beep and stop
  4. Run the plan — expand → read → if/then → click → login → notify → success_if
  5. Sleep, then repeat — configurable pause between steps and between full passes

If a click misses, the interpreter does not barrel on blindly. It falls back to the nearest earlier expand or read step and retries, up to three times per pass. That matches how these UIs fail in practice: the button is in a panel that never opened, or the DOM has not settled yet.

Navigation does not kill the session. The content script can die when the document unloads; the service worker watches tabs.onUpdated and resumes the loop on the new page while state is still RUNNING. Stop is explicit — a button, not a reload.

Instruction types

The plan is a JSON array the popup edits. Each step is one of:

TypeRole
expandOpen one accordion/panel whose heading or body contains a phrase. One click only — a second click would toggle it shut
readSnapshot body text and on-page messages for later conditions
clickClick by visible label and/or CSS selector; skip elements already marked in this pass
ifIf the snapshot contains (or does not contain) a phrase, run a nested action
success_ifStop the loop with SUCCESS when a phrase appears
waitPause a fixed number of milliseconds
loginFill username/password on a visible form, tick an agreement checkbox if present, submit
notifyPush an ntfy message from inside the plan, not only on state change

Nested then / else actions can click, expand, wait, play a beep, notify, reload, or do nothing. Two presets in the popup cover the common cases: a simple expand/read/click flow, and a decision template.

Global fields in Control (preferred button label, optional CSS selector, success phrase) fill empty click and success_if steps so a short plan still works without duplicating the same strings.

Architecture (Manifest V3)

Chrome content scripts cannot be ES modules in the usual way, so the bootstrap file is classic JS that import()s modules from chrome.runtime.getURL. The service worker is a module.

ModuleRole
popup/popup.jsControl, instruction editor, import/export, start / pause / stop
background/background.jsFind the allowed tab, inject the content script if it died, resume after navigation, send ntfy, hourly alarm
content/content.jsClassic bootstrap; starts the engine only on the allowed host
modules/engine.jsInterruptible async loop, generation token so Stop invalidates in-flight delays
modules/instructions.jsPlan interpreter, click fallbacks, nested then/else
modules/actions.jsVisible-only clicks, accordion expand, login fill (including iCheck-style hidden checkboxes)
modules/pagestate.jsSession-expired modal and incomplete-page heuristics
modules/maintenance.jsMaintenance-page phrase match
modules/notify.jsntfy JSON API (avoids non-ASCII header issues)
modules/credentials.jsSession vs local credential storage, hostname-bound
modules/hostname.jsNormalize host, exact or subdomain match
modules/stats.jsIteration / reload counters for the hourly ntfy report

A monotonic generation token sits next to a re-entrancy flag. Pause and Stop bump the generation so an old setTimeout cannot continue the previous loop. That is the same idea as cancelling a job, without AbortController plumbing through every delay.

Clicks use the element’s own .click() after scrollIntoView and focus. Firing a synthetic MouseEvent and .click() opened and closed toggle panels in the same tick — one path only.

Recovery that I actually needed

Long-running tabs fail in boring ways:

  • Maintenance interstitial — wait N seconds, reload, count it
  • Session expired modal — click the dismiss button, then the login step can run
  • Incomplete / hung page — body too short or still showing a loading phrase; wait, reassess, reload
  • Several soft failures in one pass (missed click, missing panel, login skipped) and the page still looks incomplete — treat it as hung and reload

None of that is clever. It is what kept the loop alive overnight without me watching the tab.

Phone-sized observability

ntfy is optional. On SUCCESS it sends a high-priority push. On unexpected STOPPED it sends an error. Manual stop does not notify — I already know I pressed the button.

An optional hourly status report uses chrome.alarms (60-minute period) and includes iteration count, maintenance reloads, incomplete reloads, last message, and a slice of the activity log. That is enough to know the loop is still chewing versus stuck on a blank page.

What I learned

Scope beats power. A hostname allowlist plus “no internal Chrome URLs” is simpler to reason about than a permission prompt on every click. If the wrong tab is focused, the background worker still finds an open tab for the allowed host.

UI automation is mostly recovery. The instruction list is short. The engine is long because pages lie: they look ready, then swap in a login modal; they expand, then collapse; they navigate and kill the content script.

Manifest V3 service workers are a coordinator, not the loop. The loop lives in the page. The worker’s job is message routing, injection, resume-after-navigation, and talking to ntfy — work that must survive the tab going away.

Export the plan, not the password. The popup can dump configuration to JSON for another Chrome profile. Including credentials is a checkbox that defaults off.

Try it

This is a local unpacked extension (version 1.6.5), not a store listing. Load the folder in chrome://extensions with Developer mode, pin the site hostname, build a short plan, and start the loop on that tab.

If you maintain a UI that still needs a patient, repeating operator, a hostname-scoped instruction loop is a better tool than a pile of bookmarks and a timer.